Survey plugin tweaks & bug fixes (#8188)

* Survey plugin tweaks & bug fixes

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

* ff

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>

---------

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko
2025-03-11 11:41:10 +03:00
committed by GitHub
parent f94d8bfbaf
commit 248cb26f38
9 changed files with 526 additions and 368 deletions
+2 -1
View File
@@ -5345,7 +5345,7 @@ packages:
version: 0.0.0
'@rush-temp/survey-resources@file:projects/survey-resources.tgz':
resolution: {integrity: sha512-GcIw9MyT6uttWSxYSZOdu9Kln2eLlum64WfLRrHoA0/Z6zxXdlKn6IgZwRdwo2iQBbws9Vz3sfEvoa89XCm6dw==, tarball: file:projects/survey-resources.tgz}
resolution: {integrity: sha512-OKGMUZJzuG7+t/0yBujRPBsJPraZFObMn+gF0hOPlgUeBwmFhh5fUKhodNvqAQhmaikyUI8wqf1Zt8roq1fMUg==, tarball: file:projects/survey-resources.tgz}
version: 0.0.0
'@rush-temp/survey@file:projects/survey.tgz':
@@ -25234,6 +25234,7 @@ snapshots:
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
eslint-plugin-svelte: 2.35.1(eslint@8.56.0)(svelte@4.2.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
fast-equals: 5.2.2
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
prettier: 3.2.5
prettier-plugin-svelte: 3.2.2(prettier@3.2.5)(svelte@4.2.19)
+17
View File
@@ -302,6 +302,23 @@ export class ThrottledCaller {
}
}
/**
* @public
*/
export class DebouncedCaller {
timeout?: any
constructor (readonly delay: number = 50) {}
call (op: () => void): void {
if (this.timeout !== undefined) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(() => {
op()
this.timeout = undefined
}, this.delay)
}
}
export const testing = (localStorage.getItem('#platform.testing.enabled') ?? 'false') === 'true'
export const rootBarExtensions = writable<
+2 -1
View File
@@ -46,6 +46,7 @@
"@hcengineering/view": "^0.6.13",
"@hcengineering/view-resources": "^0.6.0",
"@hcengineering/ui": "^0.6.15",
"svelte": "^4.2.19"
"svelte": "^4.2.19",
"fast-equals": "^5.2.2"
}
}
@@ -15,12 +15,12 @@
//
-->
<script lang="ts">
import { getClient } from '@hcengineering/presentation'
import { Question, QuestionKind, Poll, PollData } from '@hcengineering/survey'
import PollQuestion from './PollQuestion.svelte'
import { Poll, PollData, Question, QuestionKind } from '@hcengineering/survey'
import { createEventDispatcher } from 'svelte'
import { hasText } from '../utils'
import PollQuestion from './PollQuestion.svelte'
const client = getClient()
const dispatch = createEventDispatcher()
export let object: Poll | PollData
export let canSubmit: boolean = false
@@ -47,16 +47,14 @@
return true
}
function isPreviewMode (): boolean {
return (object as Poll)._id === undefined
function handleChange (patch: Partial<Poll>): void {
dispatch('change', patch)
}
async function saveAnswers (): Promise<void> {
if (isPreviewMode()) {
return
}
const poll = object as Poll
await client.updateDoc(poll._class, poll.space, poll._id, { questions: object.questions })
function handleQuestionChange (index: number, patch: Partial<PollQuestion>): Promise<void> | void {
const questions = (object.questions ?? []).slice()
questions[index] = { ...questions[index], ...patch }
handleChange({ questions })
}
</script>
@@ -68,13 +66,13 @@
</span>
</div>
{/if}
{#each object.questions ?? [] as question, index}
{#each object.questions ?? [] as question, index (index)}
{#if isQuestionValid(question)}
<PollQuestion
bind:this={questionNodes[index]}
bind:isAnswered={isAnswered[index]}
{readonly}
on:answered={saveAnswers}
on:change={(e) => handleQuestionChange(index, e.detail)}
{question}
/>
{/if}
@@ -17,34 +17,55 @@
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { Panel } from '@hcengineering/panel'
import { MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Poll } from '@hcengineering/survey'
import { Button, IconMoreH, showPopup } from '@hcengineering/ui'
import { Button, DebouncedCaller, IconMoreH, ThrottledCaller } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { DocNavLink, ParentsNavigator, showMenu } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import EditPoll from './EditPoll.svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
import survey from '../plugin'
import EditPoll from './EditPoll.svelte'
const client = getClient()
const dispatch = createEventDispatcher()
const query = createQuery()
const throttle = new ThrottledCaller(250)
const debounce = new DebouncedCaller(1000)
export let _id: Ref<Poll>
export let embedded: boolean = false
export let readonly: boolean = false
let object: Poll | undefined = undefined
interface Patch {
id: number
patch: Partial<Poll>
}
function combinedPatch (patches: Patch[]): Partial<Poll> {
return patches.reduce((r, c) => Object.assign(r, c.patch), {})
}
let patchCounter = 0
let patches: Patch[] = []
let objectState: Poll | undefined = undefined
$: object = objectState ? { ...objectState, ...combinedPatch(patches) } : undefined
let canSubmit = false
let requestUpdate = false
$: isCompleted = object?.isCompleted ?? false
$: editable = (!readonly && !isCompleted) || requestUpdate
$: updateObject(_id)
$: void queryObject(_id)
function updateObject (_id: Ref<Poll>): void {
async function queryObject (_id: Ref<Poll>): Promise<void> {
await flush()
objectState = undefined
patches = []
query.query(survey.class.Poll, { _id }, (result) => {
object = result[0]
objectState = result[0]
})
}
@@ -68,8 +89,30 @@
// )
await getClient().updateDoc(object._class, object.space, object._id, { isCompleted: true })
}
function handleChange (patch: Partial<Poll>): void {
patches = [...patches, { id: patchCounter++, patch }]
throttle.call(() => {
void flush()
})
}
async function flush (): Promise<void> {
if (!object?._id || patches.length < 1) return
const patchesToApply = patches.slice()
const patchIds = new Set(patchesToApply.map((p) => p.id))
const update: Partial<Poll> = combinedPatch(patchesToApply)
await client.updateDoc(object._class, object.space, object._id, update)
debounce.call(() => {
patches = patches.filter((p) => !patchIds.has(p.id))
})
}
onDestroy(flush)
</script>
<svelte:window on:beforeunload={flush} />
{#if object}
<Panel
isHeader={false}
@@ -120,7 +163,14 @@
</svelte:fragment>
<div class="flex-col flex-grow flex-no-shrink">
<EditPoll {object} readonly={!editable} bind:canSubmit />
<EditPoll
{object}
readonly={!editable}
bind:canSubmit
on:change={(e) => {
handleChange(e.detail)
}}
/>
</div>
</Panel>
{/if}
@@ -15,8 +15,7 @@
//
-->
<script lang="ts">
import { MessageBox, getClient } from '@hcengineering/presentation'
import { Question, QuestionKind, Survey } from '@hcengineering/survey'
import { Question, QuestionKind } from '@hcengineering/survey'
import {
ButtonIcon,
EditBox,
@@ -27,138 +26,86 @@
showPopup,
tooltip
} from '@hcengineering/ui'
import { deepEqual } from 'fast-equals'
import { createEventDispatcher, onDestroy } from 'svelte'
import survey from '../plugin'
const client = getClient()
const dispatch = createEventDispatcher()
export let parent: Survey
export let index: number
export let question: Question
export let readonly: boolean = false
export let isNewQuestion = false
let inputNameEditBox: EditBox
let inputNameSyncState: string = ''
let inputName: string = ''
$: if (inputNameSyncState !== question.name) {
inputNameSyncState = question.name
inputName = question.name
}
let inputOptionsSyncState: string[] = []
let inputOptions: string[] = []
let inputNewOption = ''
$: if (!deepEqual(inputOptionsSyncState, question.options ?? [])) {
inputOptionsSyncState = question.options ?? []
inputOptions = inputOptionsSyncState?.slice() ?? []
}
let editQuestion: EditBox
let hovered: boolean = false
let defaultQuestion: Question = {
name: '',
kind: QuestionKind.STRING,
isMandatory: false,
hasCustomOption: false
function handleChange (patch: Partial<Question>): void {
dispatch('change', patch)
}
$: question = (parent?.questions?.[index] as Question) ?? defaultQuestion
$: isNewQuestion = parent?.questions?.[index] === undefined
$: options = question?.options ?? []
$: questionIcon = isNewQuestion
? survey.icon.Question
: question.kind === QuestionKind.OPTIONS
? survey.icon.QuestionKindOptions
: question.kind === QuestionKind.OPTION
? survey.icon.QuestionKindOption
: survey.icon.QuestionKindString
function flush (): void {
if (isNewQuestion) return
let haveNameChanges = false
const patch: Partial<Question> = {}
let haveChanges = false
let newOption = ''
onDestroy(() => {
handleExit()
})
function handleExit (): void {
void handleNameChange()
}
async function updateParent (): Promise<void> {
await client.updateDoc(parent._class, parent.space, parent._id, { questions: parent.questions })
}
$: if (isNewQuestion && question.name.trim() !== '') {
void createQuestion()
}
function createQuestion (): Promise<void> {
if (parent.questions === undefined) {
parent.questions = []
if (inputName !== question.name) {
patch.name = inputName
haveChanges = true
}
parent.questions.push({ ...question })
defaultQuestion = { ...defaultQuestion, name: '' }
return updateParent()
}
function handleNameChange (): Promise<void> | void {
if (!haveNameChanges) return
haveNameChanges = false
if (isNewQuestion) return createQuestion()
return changeName()
}
async function changeName (): Promise<void> {
if (!isNewQuestion) {
await updateParent()
if (!deepEqual(inputOptions, question.options ?? [])) {
patch.options = inputOptions
haveChanges = true
}
if (haveChanges) handleChange(patch)
}
onDestroy(flush)
$: haveNonEmptyName = inputName.trim().length > 0
$: if (haveNonEmptyName && isNewQuestion) {
handleChange({ name: inputName })
}
async function changeKind (kind: QuestionKind): Promise<void> {
if (question.kind !== kind) {
question.kind = kind
await updateParent()
}
}
async function changeMandatory (): Promise<void> {
question.isMandatory = !question.isMandatory
await updateParent()
}
async function changeCustomOption (): Promise<void> {
question.hasCustomOption = !question.hasCustomOption
await updateParent()
}
async function changeOption (index: number): Promise<void> {
if (options[index].trim().length === 0) {
await deleteOption(index)
function changeOption (index: number): void {
if (inputOptions[index]?.trim()?.length === 0) {
deleteOption(index)
} else {
await updateParent()
handleChange({ options: inputOptions })
}
}
async function addOption (): Promise<void> {
if (newOption.trim().length === 0) {
newOption = ''
function addOption (): void {
if (inputNewOption.trim().length === 0) {
inputNewOption = ''
return
}
if (question.options === undefined) {
question.options = []
}
question.options = [...options, newOption]
await updateParent()
newOption = ''
inputOptions = [...inputOptions, inputNewOption]
inputNewOption = ''
handleChange({ options: inputOptions })
}
async function deleteOption (index: number): Promise<void> {
options.splice(index, 1)
await updateParent()
}
async function deleteQuestion (): Promise<void> {
showPopup(
MessageBox,
{
label: survey.string.DeleteQuestion,
message: survey.string.DeleteQuestionConfirm
},
undefined,
async (result?: boolean) => {
if (result === true) {
parent.questions?.splice(index, 1)
await updateParent()
}
}
)
function deleteOption (index: number): void {
inputOptions = inputOptions.filter((val, idx) => idx !== index)
handleChange({ options: inputOptions })
}
function showQuestionParams (ev: MouseEvent): void {
@@ -214,27 +161,27 @@
async (id) => {
switch (id) {
case QuestionKind.STRING: {
await changeKind(QuestionKind.STRING)
handleChange({ kind: QuestionKind.STRING })
break
}
case QuestionKind.OPTION: {
await changeKind(QuestionKind.OPTION)
handleChange({ kind: QuestionKind.OPTION })
break
}
case QuestionKind.OPTIONS: {
await changeKind(QuestionKind.OPTIONS)
handleChange({ kind: QuestionKind.OPTIONS })
break
}
case 'mandatory': {
await changeMandatory()
handleChange({ isMandatory: !question.isMandatory })
break
}
case 'custom-option': {
await changeCustomOption()
handleChange({ hasCustomOption: !question.hasCustomOption })
break
}
case 'delete': {
await deleteQuestion()
dispatch('delete')
break
}
case undefined: {
@@ -266,7 +213,7 @@
async (id) => {
switch (id) {
case 'delete': {
await deleteOption(index)
deleteOption(index)
break
}
case undefined: {
@@ -285,7 +232,7 @@
let draggedOverIndex: number | undefined
const draggableElements: HTMLElement[] = []
function dragStart (ev: DragEvent, index: number): void {
function onOptionDragStart (ev: DragEvent, index: number): void {
if (readonly || ev.dataTransfer === null) {
return
}
@@ -298,7 +245,7 @@
)
}
function dragOver (ev: DragEvent, index: number): void {
function onOptionDragOver (ev: DragEvent, index: number): void {
if (draggedIndex === undefined || draggedIndex === draggedOverIndex || draggedIndex + 1 === draggedOverIndex) {
return
}
@@ -306,7 +253,7 @@
draggedOverIndex = index
}
function dragLeave (ev: DragEvent, index: number): void {
function onOptionDragLeave (ev: DragEvent, index: number): void {
if (draggedIndex === undefined) {
return
}
@@ -316,32 +263,23 @@
}
}
async function dragDrop (): Promise<void> {
function onOptionDrop (): void {
if (draggedIndex === undefined || draggedOverIndex === undefined) {
return
}
let modified = false
if (draggedOverIndex === options.length && draggedIndex !== options.length - 1) {
options.push(options[draggedIndex])
options.splice(draggedIndex, 1)
modified = true
} else if (draggedOverIndex < draggedIndex) {
const tmp = options[draggedIndex]
options[draggedIndex] = options[draggedOverIndex]
options[draggedOverIndex] = tmp
modified = true
} else if (draggedIndex + 1 !== draggedOverIndex) {
const tmp = options[draggedIndex]
options[draggedIndex] = options[draggedOverIndex - 1]
options[draggedOverIndex - 1] = tmp
modified = true
}
if (modified) {
await updateParent()
if (draggedIndex === draggedOverIndex || draggedIndex === draggedOverIndex - 1) {
return
}
const item = inputOptions[draggedIndex]
const other = inputOptions.filter((_, index) => index !== draggedIndex)
const index = draggedIndex < draggedOverIndex ? draggedOverIndex - 1 : draggedOverIndex
inputOptions = [...other.slice(0, index), item, ...other.slice(index)]
handleChange({ options: inputOptions })
}
function dragEnd (): void {
function onOptionDragEnd (): void {
draggedIndex = undefined
draggedOverIndex = undefined
}
@@ -349,7 +287,7 @@
let isRootDragging = false
let rootElement: HTMLElement
function rootDragStart (ev: DragEvent): void {
function onRootDragStart (ev: DragEvent): void {
if (readonly || ev.dataTransfer === null) {
return
}
@@ -363,17 +301,25 @@
dispatch('dragStart')
}
function rootDragEnd (): void {
function onRootDragEnd (): void {
isRootDragging = false
dispatch('dragEnd')
}
const focusQuestion = (): void => {
editQuestion.focusInput()
export function focusQuestion (): void {
inputNameEditBox.focusInput()
}
$: questionIcon = isNewQuestion
? survey.icon.Question
: question.kind === QuestionKind.OPTIONS
? survey.icon.QuestionKindOptions
: question.kind === QuestionKind.OPTION
? survey.icon.QuestionKindOption
: survey.icon.QuestionKindString
</script>
<svelte:window on:beforeunload={handleExit} />
<svelte:window on:beforeunload={flush} />
<div
bind:this={rootElement}
class="question-container flex-col flex-gap-2"
@@ -392,22 +338,21 @@
class="self-start"
role="presentation"
draggable={!readonly}
on:dragstart={rootDragStart}
on:dragend={rootDragEnd}
on:dragstart={onRootDragStart}
on:dragend={onRootDragEnd}
>
<ButtonIcon size={'small'} disabled={readonly} icon={questionIcon} on:click={showQuestionParams} />
</div>
{/if}
<EditBox
bind:this={editQuestion}
bind:this={inputNameEditBox}
format={'text-multiline'}
disabled={readonly}
placeholder={survey.string.QuestionPlaceholderEmpty}
bind:value={question.name}
on:input={() => {
haveNameChanges = true
bind:value={inputName}
on:change={() => {
handleChange({ name: inputName })
}}
on:change={handleNameChange}
/>
{#if !isNewQuestion}
{#if question.hasCustomOption && question.kind !== QuestionKind.STRING}
@@ -423,18 +368,18 @@
{/if}
</div>
{#if !isNewQuestion && question.kind !== QuestionKind.STRING}
{#each options as option, index (index)}
{#each inputOptions as option, index (index)}
<div
class="flex-row-center flex-gap-3 option"
role="listitem"
bind:this={draggableElements[index]}
on:dragover={(ev) => {
dragOver(ev, index)
onOptionDragOver(ev, index)
}}
on:dragleave={(ev) => {
dragLeave(ev, index)
onOptionDragLeave(ev, index)
}}
on:drop={dragDrop}
on:drop={onOptionDrop}
class:is-dragged={index === draggedIndex}
class:dragged-over={draggedIndex !== undefined &&
draggedOverIndex === index &&
@@ -445,9 +390,9 @@
role="presentation"
draggable={!readonly}
on:dragstart={(ev) => {
dragStart(ev, index)
onOptionDragStart(ev, index)
}}
on:dragend={dragEnd}
on:dragend={onOptionDragEnd}
>
<ButtonIcon
disabled={readonly}
@@ -463,9 +408,9 @@
<EditBox
disabled={readonly}
placeholder={survey.string.QuestionPlaceholderOption}
bind:value={options[index]}
on:change={async () => {
await changeOption(index)
bind:value={inputOptions[index]}
on:change={() => {
changeOption(index)
}}
/>
</div>
@@ -475,16 +420,20 @@
class="flex-row-center flex-gap-3 option"
role="listitem"
on:dragover={(ev) => {
dragOver(ev, options.length)
onOptionDragOver(ev, question.options?.length ?? 0)
}}
on:dragleave={(ev) => {
dragLeave(ev, options.length)
onOptionDragLeave(ev, question.options?.length ?? 0)
}}
on:drop={dragDrop}
class:dragged-over={draggedOverIndex === options.length && draggedIndex !== options.length - 1}
on:drop={onOptionDrop}
class:dragged-over={draggedOverIndex === inputOptions.length && draggedIndex !== inputOptions.length - 1}
>
<ButtonIcon disabled icon={survey.icon.Question} iconSize={'x-small'} kind={'tertiary'} size={'extra-small'} />
<EditBox placeholder={survey.string.QuestionPlaceholderOption} bind:value={newOption} on:change={addOption} />
<EditBox
placeholder={survey.string.QuestionPlaceholderOption}
bind:value={inputNewOption}
on:change={addOption}
/>
</div>
{/if}
{/if}
@@ -15,34 +15,76 @@
//
-->
<script lang="ts">
import { getClient } from '@hcengineering/presentation'
import { Survey } from '@hcengineering/survey'
import { EditBox, FocusHandler, Label, createFocusManager, Icon } from '@hcengineering/ui'
import EditQuestion from './EditQuestion.svelte'
import { MessageBox } from '@hcengineering/presentation'
import { Question, QuestionKind, Survey } from '@hcengineering/survey'
import { createFocusManager, EditBox, FocusHandler, Icon, Label, showPopup } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import survey from '../plugin'
import EditQuestion from './EditQuestion.svelte'
import IconQuestion from './icons/Question.svelte'
const dispatch = createEventDispatcher()
const manager = createFocusManager()
const client = getClient()
export let object: Survey
export let readonly: boolean = false
$: questions = object?.questions ?? []
$: questionEditSlots = readonly ? questions : [...questions, {}]
async function nameChange (): Promise<void> {
await client.updateDoc(object._class, object.space, object._id, { name: object.name })
const emptyQuestion: Question = {
name: '',
kind: QuestionKind.STRING,
isMandatory: false,
hasCustomOption: false
}
async function promptChange (): Promise<void> {
await client.updateDoc(object._class, object.space, object._id, { prompt: object.prompt })
let newQuestion: Question = { ...emptyQuestion }
let newQuestionComponent: EditQuestion
const questionComponents: EditQuestion[] = []
function handleChange (patch: Partial<Survey>): void {
dispatch('change', patch)
}
function deleteQuestion (index: number): void {
if (!object.questions?.[index]) return
showPopup(
MessageBox,
{
label: survey.string.DeleteQuestion,
message: survey.string.DeleteQuestionConfirm
},
undefined,
async (result?: boolean) => {
if (result === true) {
let questions = object.questions ?? []
questions = questions.filter((q, i) => i !== index)
handleChange({ questions })
}
}
)
}
function handleNewQuestionChange (patch: Partial<Question>): void {
const question = { ...newQuestion, ...patch }
let questions = object.questions ?? []
questions = [...questions, question]
newQuestion = { ...emptyQuestion }
handleChange({ questions })
}
function handleQuestionChange (index: number, patch: Partial<Question>): Promise<void> | void {
const questions = (object.questions ?? []).slice()
questions[index] = { ...questions[index], ...patch }
handleChange({ questions })
}
let draggedIndex: number | undefined = undefined
let draggedOverIndex: number | undefined = undefined
function dragOver (ev: DragEvent, index: number): void {
function onQuestionDragOver (ev: DragEvent, index: number): void {
if (draggedIndex === undefined || draggedIndex === draggedOverIndex || draggedIndex + 1 === draggedOverIndex) {
return
}
@@ -50,40 +92,32 @@
draggedOverIndex = index
}
function dragLeave (ev: DragEvent, index: number): void {
if (draggedIndex === undefined) {
return
}
function onQuestionDragLeave (ev: DragEvent, index: number): void {
if (draggedIndex === undefined) return
ev.preventDefault()
if (draggedOverIndex === index) {
draggedOverIndex = undefined
}
if (draggedOverIndex === index) draggedOverIndex = undefined
}
async function dragDrop (): Promise<void> {
function onQuestionDrop (): void {
if (draggedIndex === undefined || draggedOverIndex === undefined) {
return
}
let modified = false
if (draggedOverIndex === questions.length && draggedIndex !== questions.length - 1) {
questions.push(questions[draggedIndex])
questions.splice(draggedIndex, 1)
modified = true
} else if (draggedOverIndex < draggedIndex) {
const tmp = questions[draggedIndex]
questions[draggedIndex] = questions[draggedOverIndex]
questions[draggedOverIndex] = tmp
modified = true
} else if (draggedIndex + 1 !== draggedOverIndex) {
const tmp = questions[draggedIndex]
questions[draggedIndex] = questions[draggedOverIndex - 1]
questions[draggedOverIndex - 1] = tmp
modified = true
}
if (modified) {
await client.updateDoc(object._class, object.space, object._id, { questions })
if (draggedIndex === draggedOverIndex || draggedIndex === draggedOverIndex - 1) {
return
}
let questions = object?.questions ?? []
const item = questions[draggedIndex]
const other = questions.filter((_, index) => index !== draggedIndex)
const index = draggedIndex < draggedOverIndex ? draggedOverIndex - 1 : draggedOverIndex
questions = [...other.slice(0, index), item, ...other.slice(index)]
questionComponents[index]?.focusQuestion()
handleChange({ questions })
}
$: questionList = [...(object.questions ?? []), newQuestion]
</script>
<FocusHandler {manager} />
@@ -95,7 +129,9 @@
placeholder={survey.string.Name}
bind:value={object.name}
kind={'large-style'}
on:change={nameChange}
on:input={() => {
handleChange({ name: object.name })
}}
/>
</div>
<div class="step-tb-6">
@@ -103,7 +139,9 @@
disabled={readonly}
placeholder={survey.string.PromptPlaceholder}
bind:value={object.prompt}
on:change={promptChange}
on:input={() => {
handleChange({ prompt: object.prompt })
}}
/>
</div>
<div class="antiSection step-tb-6">
@@ -115,42 +153,48 @@
<Label label={survey.string.Questions} />
</span>
</div>
{#each questionEditSlots as question, index}
{#each questionList as question, index (index)}
{@const isNewQuestion = index === questionList.length - 1}
<div
role="listitem"
on:dragover={(ev) => {
dragOver(ev, index)
onQuestionDragOver(ev, index)
}}
on:dragleave={(ev) => {
dragLeave(ev, index)
onQuestionDragLeave(ev, index)
}}
on:drop={dragDrop}
on:drop={onQuestionDrop}
class:dragged-over={draggedIndex !== undefined &&
draggedOverIndex === index &&
draggedOverIndex !== draggedIndex &&
draggedOverIndex !== draggedIndex + 1}
>
{#key index}
<EditQuestion
{index}
{readonly}
parent={object}
on:dragStart={() => {
draggedIndex = index
}}
on:dragEnd={() => {
draggedIndex = undefined
draggedOverIndex = undefined
}}
/>
{/key}
<EditQuestion
bind:this={questionComponents[index]}
{question}
{isNewQuestion}
on:delete={() => {
deleteQuestion(index)
}}
on:change={(e) => {
isNewQuestion ? handleNewQuestionChange(e.detail) : handleQuestionChange(index, e.detail)
}}
{readonly}
on:dragStart={() => {
draggedIndex = index
}}
on:dragEnd={() => {
draggedIndex = undefined
draggedOverIndex = undefined
}}
/>
</div>
{/each}
</div>
{/if}
<style lang="scss">
div[role='listitem'] + div[role='listitem'] {
div[role='listitem'] {
margin-top: var(--spacing-1);
}
.dragged-over {
@@ -17,38 +17,90 @@
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { Panel } from '@hcengineering/panel'
import { createQuery } from '@hcengineering/presentation'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Survey } from '@hcengineering/survey'
import { Button, Icon, IconMoreH, Label, tooltip, Breadcrumb } from '@hcengineering/ui'
import {
Breadcrumb,
Button,
DebouncedCaller,
Icon,
IconMoreH,
Label,
ThrottledCaller,
tooltip
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { showMenu } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import EditSurvey from './EditSurvey.svelte'
import EditPoll from './EditPoll.svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
import survey from '../plugin'
import { makePollData } from '../utils'
import EditPoll from './EditPoll.svelte'
import EditSurvey from './EditSurvey.svelte'
const client = getClient()
const dispatch = createEventDispatcher()
const query = createQuery()
const throttle = new ThrottledCaller(250)
const debounce = new DebouncedCaller(1000)
export let _id: Ref<Survey>
export let embedded: boolean = false
export let readonly: boolean = false
let object: Survey | undefined = undefined
interface Patch {
id: number
patch: Partial<Survey>
}
function combinedPatch (patches: Patch[]): Partial<Survey> {
return patches.reduce((r, c) => Object.assign(r, c.patch), {})
}
let patchCounter = 0
let patches: Patch[] = []
let objectState: Survey | undefined = undefined
$: object = objectState ? { ...objectState, ...combinedPatch(patches) } : undefined
let preview = false
let canSubmit = false
$: updateObject(_id)
$: void queryObject(_id)
$: poll = preview && object !== undefined ? makePollData(object) : undefined
function updateObject (_id: Ref<Survey>): void {
async function queryObject (_id: Ref<Survey>): Promise<void> {
await flush()
objectState = undefined
patches = []
query.query(survey.class.Survey, { _id }, (result) => {
object = result[0]
objectState = result[0]
})
}
function handleChange (patch: Partial<Survey>): void {
patches = [...patches, { id: patchCounter++, patch }]
throttle.call(() => {
void flush()
})
}
async function flush (): Promise<void> {
if (!object?._id || patches.length < 1) return
const patchesToApply = patches.slice()
const patchIds = new Set(patchesToApply.map((p) => p.id))
const update: Partial<Survey> = combinedPatch(patchesToApply)
await client.updateDoc(object._class, object.space, object._id, update)
debounce.call(() => {
patches = patches.filter((p) => !patchIds.has(p.id))
})
}
onDestroy(flush)
</script>
<svelte:window on:beforeunload={flush} />
{#if object}
<Panel
isHeader={false}
@@ -109,7 +161,13 @@
<EditPoll object={poll} bind:canSubmit />
{/if}
{:else}
<EditSurvey {object} {readonly} />
<EditSurvey
{object}
on:change={(e) => {
handleChange(e.detail)
}}
{readonly}
/>
{/if}
</div>
</Panel>
@@ -16,9 +16,10 @@
-->
<script lang="ts">
import { generateId } from '@hcengineering/core'
import { EditBox, Icon, Label, tooltip, ModernCheckbox, ModernRadioButton } from '@hcengineering/ui'
import { AnsweredQuestion, QuestionKind } from '@hcengineering/survey'
import { createEventDispatcher } from 'svelte'
import { EditBox, Icon, Label, ModernCheckbox, ModernRadioButton, tooltip } from '@hcengineering/ui'
import { deepEqual } from 'fast-equals'
import { createEventDispatcher, onDestroy } from 'svelte'
import survey from '../plugin'
import { hasText } from '../utils'
@@ -28,113 +29,147 @@
export let isAnswered: boolean = false
export let readonly: boolean = false
let answer = ''
let selectedOption: number | undefined
const selectedOptions: boolean[] = []
const customOption = -1
interface InputState {
answer: string
option?: number
options: boolean[]
}
const input: InputState = {
answer: '',
option: undefined,
options: (question.options ?? []).map(() => false)
}
$: id = generateId()
$: showAnswers(question)
$: updateIsAnswered(question, selectedOption, selectedOptions)
$: syncInputState(question)
$: validateAnswer(question, input)
function showAnswers (question: AnsweredQuestion): void {
if (question.kind === QuestionKind.STRING) {
answer = question.answer ?? ''
} else if (question.kind === QuestionKind.OPTION) {
selectedOption = question.answers?.[0]
if ((question.answers === undefined || question.answers === null) && typeof question.answer === 'string') {
selectedOption = customOption
answer = question.answer
function flush (): void {
const patch: Partial<AnsweredQuestion> = {}
let haveChanges = false
const current = extractCurrentAnswerSet(input)
if (!isSameAnswers(current, question)) {
patch.answer = current.answer
patch.answers = current.answers
haveChanges = true
}
if (haveChanges) handleChange(patch)
}
onDestroy(flush)
function syncInputState (question: AnsweredQuestion): void {
const current = extractCurrentAnswerSet(input)
if (isSameAnswers(current, question)) return
input.answer = question.answer ?? ''
input.option =
question.kind === QuestionKind.OPTION
? typeof question.answer === 'string'
? customOption
: question.answers?.[0]
: undefined
const answerIds = new Set(question.answers ?? [])
input.options = (question.options ?? []).map((_, idx) => answerIds.has(idx))
if (question.kind === QuestionKind.OPTIONS && typeof question.answer === 'string') {
input.options[customOption] = true
}
}
function validateAnswer (question: AnsweredQuestion, input: InputState): void {
isAnswered = isValidAnswer(question, input)
}
function isValidAnswer (question: AnsweredQuestion, input: InputState): boolean {
if (!question.isMandatory) return true
switch (question.kind) {
case QuestionKind.STRING:
return hasText(input.answer)
case QuestionKind.OPTION:
return input.option === customOption ? hasText(input.answer) : input.option !== undefined
case QuestionKind.OPTIONS:
return input.options[customOption] ? hasText(input.answer) : input.options.some((on) => on)
default:
return false
}
}
function handleChange (patch: Partial<AnsweredQuestion>): void {
dispatch('change', patch)
}
function handleAnswerChange (): void {
const patch = extractCurrentAnswerSet(input)
handleChange(patch)
}
interface AnswerSet {
answer?: string
answers?: number[]
}
function isSameAnswers (a1: AnswerSet, a2: AnswerSet): boolean {
return (a1.answer ?? null) === (a2.answer ?? null) && deepEqual(a1.answers ?? null, a2.answers ?? null)
}
function extractCurrentAnswerSet (input: InputState): AnswerSet {
switch (question.kind) {
case QuestionKind.STRING: {
const answer = hasText(input.answer) ? input.answer : undefined
return { answer, answers: undefined }
}
} else if (question.kind === QuestionKind.OPTIONS) {
question.answers?.forEach((index) => {
selectedOptions[index] = true
})
if (typeof question.answer === 'string') {
selectedOptions[customOption] = true
answer = question.answer
case QuestionKind.OPTION: {
const answer: string | undefined = input.option === customOption ? input.answer : undefined
const answers: number[] | undefined =
input.option !== undefined && input.option !== customOption ? [input.option] : undefined
return { answer, answers }
}
case QuestionKind.OPTIONS: {
const toggledOptions: number[] = input.options
.map((state, idx) => [state, idx] as const)
.filter((a) => a[0])
.map((a) => a[1])
const answers = toggledOptions.length > 0 ? toggledOptions : undefined
const answer = input.options[customOption] ? input.answer : undefined
return { answer, answers }
}
}
}
function updateIsAnswered (
question: AnsweredQuestion,
selectedOption: number | undefined,
selectedOptions: boolean[]
): void {
if (!question.isMandatory) {
isAnswered = true
return
}
if (question.kind === QuestionKind.STRING) {
isAnswered = hasText(answer)
} else if (question.kind === QuestionKind.OPTION) {
isAnswered = selectedOption === customOption ? hasText(answer) : selectedOption !== undefined
} else if (question.kind === QuestionKind.OPTIONS) {
isAnswered = selectedOptions[customOption] ? hasText(answer) : selectedOptions.some((on) => on)
}
}
function answerChange (): void {
question.answer = hasText(answer) ? answer : undefined
dispatch('answered')
}
function optionChange (): void {
if (selectedOption === undefined) {
question.answer = undefined
question.answers = undefined
} else if (selectedOption === customOption) {
question.answer = answer
question.answers = undefined
} else {
question.answer = undefined
question.answers = [selectedOption]
}
dispatch('answered')
}
function optionsChange (): void {
const answers: number[] = []
selectedOptions.forEach((on, index) => {
if (on) {
answers.push(index)
}
})
question.answers = answers.length > 0 ? answers : undefined
question.answer = selectedOptions[customOption] ? answer : undefined
dispatch('answered')
}
function getReadonlyAnswers (): string[] {
if (question.kind === QuestionKind.STRING) {
return [answer.trim()]
}
if (question.kind === QuestionKind.OPTION) {
if (selectedOption === undefined) {
return []
}
if (selectedOption === customOption) {
return [answer.trim()]
}
return [question.options?.[selectedOption] ?? '']
}
if (question.kind === QuestionKind.OPTIONS) {
const answers: string[] = []
question.options?.forEach((option, index) => {
if (selectedOptions[index]) {
answers.push(option)
switch (question.kind) {
case QuestionKind.STRING:
return [input.answer.trim()]
case QuestionKind.OPTION:
if (input.option === undefined) {
return []
}
})
if (selectedOptions[customOption]) {
answers.push(answer.trim())
if (input.option === customOption) {
return [input.answer.trim()]
}
return [question.options?.[input.option] ?? '']
case QuestionKind.OPTIONS: {
const answers: string[] = (question.options ?? []).filter((_, idx) => input.options[idx])
if (input.options[customOption]) {
answers.push(input.answer.trim())
}
return answers
}
return answers
default:
return []
}
return []
}
</script>
<svelte:window on:beforeunload={flush} />
<div class="question-answer-container flex-col flex-gap-3">
<div class="flex-row-center flex-gap-1 flex-no-shrink">
<strong class="text-base caption-color font-medium pre-wrap">{question.name}</strong>
@@ -165,8 +200,8 @@
id={`${id}-${i}`}
value={i}
label={option}
bind:group={selectedOption}
on:change={optionChange}
bind:group={input.option}
on:change={handleAnswerChange}
/>
{/each}
{#if question.hasCustomOption}
@@ -174,17 +209,17 @@
id={`${id}-custom`}
value={customOption}
labelIntl={survey.string.AnswerCustomOption}
bind:group={selectedOption}
on:change={optionChange}
bind:group={input.option}
on:change={handleAnswerChange}
/>
{#if selectedOption === customOption}
{#if input.option === customOption}
<div class="pl-6">
<EditBox
bind:value={answer}
bind:value={input.answer}
placeholder={survey.string.AnswerPlaceholder}
focusable
autoFocus
on:change={answerChange}
on:change={handleAnswerChange}
/>
</div>
{/if}
@@ -193,23 +228,28 @@
{:else if question.kind === QuestionKind.OPTIONS}
<div class="flex-col flex-gap-2 px-6">
{#each question.options ?? [] as option, i}
<ModernCheckbox id={`${id}-${i}`} label={option} bind:checked={selectedOptions[i]} on:change={optionsChange} />
<ModernCheckbox
id={`${id}-${i}`}
label={option}
bind:checked={input.options[i]}
on:change={handleAnswerChange}
/>
{/each}
{#if question.hasCustomOption}
<ModernCheckbox
id={`${id}-custom`}
labelIntl={survey.string.AnswerCustomOption}
bind:checked={selectedOptions[customOption]}
on:change={optionsChange}
bind:checked={input.options[customOption]}
on:change={handleAnswerChange}
/>
{#if selectedOptions[customOption]}
{#if input.options[customOption]}
<div class="pl-6">
<EditBox
bind:value={answer}
bind:value={input.answer}
placeholder={survey.string.AnswerPlaceholder}
focusable
autoFocus
on:change={answerChange}
on:change={handleAnswerChange}
/>
</div>
{/if}
@@ -219,9 +259,9 @@
<div>
<EditBox
format={'text-multiline'}
bind:value={answer}
bind:value={input.answer}
placeholder={survey.string.AnswerPlaceholder}
on:change={answerChange}
on:change={handleAnswerChange}
/>
</div>
{/if}