show polls in panel, add surveys to vacancies and candidates (#7176)

Signed-off-by: Nikolay Chunosov <Chunosov.N@gmail.com>
This commit is contained in:
Chunosov 2024-11-15 11:53:30 +07:00 committed by GitHub
parent 8bfeba7303
commit 123ae25b68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 927 additions and 508 deletions

View File

@ -18,7 +18,7 @@ import {
Account,
IndexKind,
type CollaborativeDoc,
type Collection as Array,
type Collection,
type Domain,
type Markup,
type Ref,
@ -28,7 +28,7 @@ import {
type Timestamp
} from '@hcengineering/core'
import {
Collection,
Collection as TypeCollection,
Hidden,
Index,
Mixin,
@ -70,7 +70,9 @@ export class TVacancy extends TProject implements Vacancy {
@Index(IndexKind.FullText)
fullDescription!: CollaborativeDoc
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
@Prop(TypeCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files
})
attachments?: number
@Prop(TypeDate(), recruit.string.Due, recruit.icon.Calendar)
@ -83,13 +85,16 @@ export class TVacancy extends TProject implements Vacancy {
@Prop(TypeRef(contact.class.Organization), recruit.string.Company, { icon: contact.icon.Company })
company?: Ref<Organization>
@Prop(Collection(chunter.class.ChatMessage), chunter.string.Comments)
@Prop(TypeCollection(chunter.class.ChatMessage), chunter.string.Comments)
comments?: number
@Prop(TypeString(), recruit.string.Vacancy)
@Index(IndexKind.FullText)
@Hidden()
number!: number
@Prop(TypeCollection(survey.class.Poll), survey.string.Polls)
polls?: Collection<Poll>
}
@Mixin(recruit.mixin.Candidate, contact.class.Person)
@ -99,7 +104,7 @@ export class TCandidate extends TPerson implements Candidate {
@Index(IndexKind.FullText)
title?: string
@Prop(Collection(recruit.class.Applicant), recruit.string.Applications, {
@Prop(TypeCollection(recruit.class.Applicant), recruit.string.Applications, {
shortLabel: recruit.string.ApplicationsShort
})
applications?: number
@ -114,26 +119,29 @@ export class TCandidate extends TPerson implements Candidate {
@Index(IndexKind.FullText)
source?: string
@Prop(Collection(tags.class.TagReference, recruit.string.SkillLabel), recruit.string.SkillsLabel, {
@Prop(TypeCollection(tags.class.TagReference, recruit.string.SkillLabel), recruit.string.SkillsLabel, {
icon: recruit.icon.Skills,
schema: '3'
})
skills?: number
@Prop(Collection(recruit.class.Review, recruit.string.Review), recruit.string.Reviews)
@Prop(TypeCollection(recruit.class.Review, recruit.string.Review), recruit.string.Reviews)
reviews?: number
@Prop(
Collection(recruit.class.ApplicantMatch, getEmbeddedLabel('Vacancy match')),
TypeCollection(recruit.class.ApplicantMatch, getEmbeddedLabel('Vacancy match')),
getEmbeddedLabel('Vacancy Matches')
)
vacancyMatch?: number
@Prop(TypeCollection(survey.class.Poll), survey.string.Polls)
polls?: Collection<Poll>
}
@Mixin(recruit.mixin.VacancyList, contact.class.Organization)
@UX(recruit.string.VacancyList, recruit.icon.RecruitApplication, 'CM', 'name')
export class TVacancyList extends TOrganization implements VacancyList {
@Prop(Collection(recruit.class.Vacancy), recruit.string.Vacancies)
@Prop(TypeCollection(recruit.class.Vacancy), recruit.string.Vacancies)
vacancies!: number
}
@ -162,8 +170,8 @@ export class TApplicant extends TTask implements Applicant {
@Index(IndexKind.Indexed)
declare status: Ref<Status>
@Prop(Collection(survey.class.Poll), survey.string.Polls)
polls?: Array<Poll>
@Prop(TypeCollection(survey.class.Poll), survey.string.Polls)
polls?: Collection<Poll>
}
@Model(recruit.class.ApplicantMatch, core.class.AttachedDoc, DOMAIN_TASK)
@ -211,7 +219,7 @@ export class TReview extends TEvent implements Review {
@Prop(TypeRef(contact.class.Organization), recruit.string.Company, { icon: contact.icon.Company })
company?: Ref<Organization>
@Prop(Collection(recruit.class.Opinion), recruit.string.Opinions)
@Prop(TypeCollection(recruit.class.Opinion), recruit.string.Opinions)
opinions?: number
}
@ -225,10 +233,12 @@ export class TOpinion extends TAttachedDoc implements Opinion {
@Prop(TypeRef(recruit.class.Review), recruit.string.Review)
declare attachedTo: Ref<Review>
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
@Prop(TypeCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files
})
attachments?: number
@Prop(Collection(chunter.class.ChatMessage), chunter.string.Comments)
@Prop(TypeCollection(chunter.class.ChatMessage), chunter.string.Comments)
comments?: number
@Prop(TypeMarkup(), recruit.string.Description)

View File

@ -38,6 +38,7 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/survey": "^0.6.0",
"@hcengineering/survey-resources": "^0.6.0",
"@hcengineering/ui": "^0.6.15"
"@hcengineering/ui": "^0.6.15",
"@hcengineering/view": "^0.6.13"
}
}

View File

@ -18,7 +18,7 @@ import { AccountRole } from '@hcengineering/core'
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import chunter from '@hcengineering/model-chunter'
import view, { type Viewlet } from '@hcengineering/model-view'
import view, { createAction, type Viewlet } from '@hcengineering/model-view'
import workbench from '@hcengineering/model-workbench'
import { surveyId } from '@hcengineering/survey'
import { TPoll, TSurvey } from './types'
@ -79,6 +79,14 @@ export function createModel (builder: Builder): void {
survey.viewlet.TableSurvey
)
builder.mixin(survey.class.Survey, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: survey.function.SurveyTitleProvider
})
builder.mixin(survey.class.Survey, core.class.Class, view.mixin.LinkProvider, {
encode: survey.function.GetSurveyLink
})
builder.mixin(survey.class.Survey, core.class.Class, view.mixin.ObjectPanel, {
component: survey.component.EditSurveyPanel
})
@ -100,20 +108,58 @@ export function createModel (builder: Builder): void {
{
attachTo: survey.class.Poll,
descriptor: view.viewlet.Table,
config: ['', 'modifiedOn'],
config: ['', 'isCompleted', 'modifiedOn'],
configOptions: {
hiddenKeys: ['name', 'survey', 'results'],
hiddenKeys: ['name', 'survey', 'questions'],
sortable: true
}
},
survey.viewlet.TablePoll
)
builder.mixin(survey.class.Poll, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: survey.function.PollTitleProvider
})
builder.mixin(survey.class.Poll, core.class.Class, view.mixin.LinkProvider, {
encode: survey.function.GetPollLink
})
builder.mixin(survey.class.Poll, core.class.Class, view.mixin.ObjectPanel, {
component: survey.component.EditPollPanel
})
builder.mixin(survey.class.Poll, core.class.Class, view.mixin.ObjectPresenter, {
presenter: survey.component.PollPresenter
})
builder.mixin(survey.class.Poll, core.class.Class, activity.mixin.ActivityDoc, {})
builder.createDoc(activity.class.ActivityExtension, core.space.Model, {
ofClass: survey.class.Poll,
components: { input: chunter.component.ChatMessageInput }
})
builder.mixin(survey.class.Poll, core.class.Class, view.mixin.CollectionEditor, {
editor: survey.component.PollCollection
})
createAction(
builder,
{
action: survey.actionImpl.DeletePoll,
label: workbench.string.Delete,
icon: view.icon.Delete,
input: 'any',
category: survey.category.Survey,
target: survey.class.Poll,
context: {
mode: ['context', 'browser'],
group: 'remove'
},
visibilityTester: view.function.CanDeleteObject,
override: [view.action.Delete]
},
survey.action.DeletePoll
)
}

View File

@ -14,7 +14,18 @@
//
import { IndexKind, type Domain, type Ref } from '@hcengineering/core'
import { ArrOf, Hidden, Index, Model, Prop, TypeRecord, TypeRef, TypeString, UX } from '@hcengineering/model'
import {
ArrOf,
Hidden,
Index,
Model,
Prop,
TypeBoolean,
TypeRecord,
TypeRef,
TypeString,
UX
} from '@hcengineering/model'
import core, { TAttachedDoc, TDoc } from '@hcengineering/model-core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { type Poll, type Question, type Survey } from '@hcengineering/survey'
@ -48,7 +59,10 @@ export class TPoll extends TAttachedDoc implements Poll {
@Prop(TypeString(), survey.string.Prompt)
prompt!: string
@Prop(ArrOf(TypeRecord()), getEmbeddedLabel('Answers'))
@Prop(ArrOf(TypeRecord()), getEmbeddedLabel('Questions'))
@Hidden()
results?: { question: string, answer: string[] }[]
questions?: Question[]
@Prop(TypeBoolean(), survey.string.Completed)
isCompleted?: boolean
}

View File

@ -54,6 +54,8 @@
"@hcengineering/presentation": "^0.6.3",
"@hcengineering/recruit": "^0.6.29",
"@hcengineering/rekoni": "^0.6.0",
"@hcengineering/survey": "^0.6.0",
"@hcengineering/survey-resources": "^0.6.0",
"@hcengineering/tags": "^0.6.16",
"@hcengineering/tags-resources": "^0.6.0",
"@hcengineering/task": "^0.6.20",

View File

@ -21,6 +21,7 @@
import { getResource } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { Vacancy } from '@hcengineering/recruit'
import survey from '@hcengineering/survey'
import tracker from '@hcengineering/tracker'
import { Button, Component, EditBox, IconMixin, IconMoreH, Label } from '@hcengineering/ui'
import view from '@hcengineering/view'
@ -202,6 +203,9 @@
<div class="w-full mt-6">
<VacancyApplications objectId={object._id} {readonly} />
</div>
<div class="w-full mt-6">
<Component is={survey.component.PollCollection} props={{ object, label: survey.string.Polls }} />
</div>
<div class="w-full mt-6">
<Component is={tracker.component.RelatedIssuesSection} props={{ object, label: tracker.string.RelatedIssues }} />
</div>

View File

@ -10,6 +10,8 @@ import {
type Vacancy,
type VacancyList
} from '@hcengineering/recruit'
import { type Poll } from '@hcengineering/survey'
import { generatePollLocation } from '@hcengineering/survey-resources'
import { getCurrentResolvedLocation, getPanelURI, type Location, type ResolvedLocation } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { accessDeniedStore } from '@hcengineering/view-resources'
@ -34,6 +36,10 @@ export async function resolveLocation (loc: Location): Promise<ResolvedLocation
return undefined
}
if (loc.path[3] === 'poll') {
return await generatePollLocation(loc, loc.path[4] as Ref<Poll>)
}
const shortLink = loc.path[3]
// shortlink

View File

@ -38,6 +38,7 @@ export interface Vacancy extends Project {
company?: Ref<Organization>
comments?: number
number: number
polls?: Collection<Poll>
}
/** @public */
@ -54,6 +55,7 @@ export interface Candidate extends Person {
source?: string
skills?: number
reviews?: number
polls?: Collection<Poll>
}
/** @public */

View File

@ -24,4 +24,17 @@
<symbol id="question" viewBox="0 0 16 16">
<path d="M4.475 5.458c-.284 0-.514-.237-.47-.517C4.28 3.24 5.576 2 7.825 2c2.25 0 3.767 1.36 3.767 3.215 0 1.344-.665 2.288-1.79 2.973-1.1.659-1.414 1.118-1.414 2.01v.03a.5.5 0 01-.5.5h-.77a.5.5 0 01-.5-.495l-.003-.2c-.043-1.221.477-2.001 1.645-2.712 1.03-.632 1.397-1.135 1.397-2.028 0-.979-.758-1.698-1.926-1.698-1.009 0-1.71.529-1.938 1.402-.066.254-.278.461-.54.461h-.777zM7.496 14c.622 0 1.095-.474 1.095-1.09 0-.618-.473-1.092-1.095-1.092-.606 0-1.087.474-1.087 1.091S6.89 14 7.496 14z" />
</symbol>
<symbol id="submit" viewBox="0 0 32 32">
<path d="M4 8C4 5.79086 5.79086 4 8 4H21C21.5523 4 22 4.44772 22 5C22 5.55228 21.5523 6 21 6H8C6.89543 6 6 6.89543 6 8V24C6 25.1046 6.89543 26 8 26H24C25.1046 26 26 25.1046 26 24V17C26 16.4477 26.4477 16 27 16C27.5523 16 28 16.4477 28 17V24C28 26.2091 26.2091 28 24 28H8C5.79086 28 4 26.2091 4 24V8ZM29.7071 6.29289C30.0976 6.68342 30.0976 7.31658 29.7071 7.70711L17.7071 19.7071C17.3166 20.0976 16.6834 20.0976 16.2929 19.7071L10.2929 13.7071C9.90237 13.3166 9.90237 12.6834 10.2929 12.2929C10.6834 11.9024 11.3166 11.9024 11.7071 12.2929L17 17.5858L28.2929 6.29289C28.6834 5.90237 29.3166 5.90237 29.7071 6.29289Z" />
</symbol>
<symbol id="validate-ok" viewBox="0 0 1024 1024">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z" />
</symbol>
<symbol id="validate-fail" viewBox="0 0 24 24">
<path d="M16.707 2.293A.996.996 0 0016 2H8a.996.996 0 00-.707.293l-5 5A.996.996 0 002 8v8c0 .266.105.52.293.707l5 5A.996.996 0 008 22h8c.266 0 .52-.105.707-.293l5-5A.996.996 0 0022 16V8a.996.996 0 00-.293-.707l-5-5zM13 17h-2v-2h2v2zm0-4h-2V7h2v6z" />
</symbol>
<symbol id="info" viewBox="0 0 16 16">
<path d="M7 4.75c0-.412.338-.75.75-.75h.5c.412 0 .75.338.75.75v.5c0 .412-.338.75-.75.75h-.5A.753.753 0 017 5.25v-.5zM10 12H6v-1h1V8H6V7h3v4h1z" />
<path d="M8 0a8 8 0 100 16A8 8 0 008 0zm0 14.5a6.5 6.5 0 110-13 6.5 6.5 0 010 13z" />
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -6,9 +6,12 @@
"Application": "Survey",
"Close": "Close",
"Control": "Control",
"Completed": "Completed",
"CreatePoll": "Make a survey",
"CreateSurvey": "Survey",
"DeleteOption": "Delete Option",
"DeletePoll": "Delete Survey",
"DeletePollConfirm": "Are you sure you want to delete the survey?",
"DeleteQuestion": "Delete Question",
"DeleteQuestionConfirm": "Are you sure you want to delete the question?",
"Name": "Title",
@ -27,12 +30,19 @@
"QuestionKindOptions": "Select several options",
"QuestionIsMandatory": "Is mandatory",
"QuestionHasCustomOption": "Has custom option",
"QuestionOptionPlaceholder": "Add an option",
"QuestionPlaceholder": "Add a question",
"QuestionEmptyPlaceholder": "Provide question text",
"QuestionPlaceholderEmpty": "Provide question text",
"QuestionPlaceholderOption": "Add an option",
"QuestionTooltipMandatory": "Answer is required",
"QuestionTooltipCustomOption": "User can provide their own option",
"Survey": "Survey Form",
"Surveys": "Survey Forms",
"SurveyEdit": "Edit Form",
"SurveyPreview": "Preview Form",
"SurveySubmit": "Submit"
"SurveySubmit": "Submit",
"SurveySubmitConfirm": "You will not be able to change answers after that. Are you sure you want to submit now?",
"ValidateFail": "Some required questions are not answered",
"ValidateInfo": "This is how the form will look like for an user. Try to type answers and select options to test your survey. A green icon in the header above shows the form is filled properly",
"ValidateOk": "Form is filled correctly"
}
}

View File

@ -6,9 +6,12 @@
"Application": "Опросник",
"Close": "Закрыть",
"Control": "Управление",
"Completed": "Завершен",
"CreatePoll": "Провести опрос",
"CreateSurvey": "Анкета",
"DeleteOption": "Удалить вариант",
"DeletePoll": "Удалить опрос",
"DeletePollConfirm": "Уверены, что хотите удалить опрос?",
"DeleteQuestion": "Удалить вопрос",
"DeleteQuestionConfirm": "Уверены, что хотите удалить вопрос?",
"Name": "Заголовок",
@ -27,12 +30,19 @@
"QuestionKindOptions": "Выбор нескольких вариантов",
"QuestionIsMandatory": "Обязательный вопрос",
"QuestionHasCustomOption": "Есть свой вариант",
"QuestionOptionPlaceholder": "Добавить вариант",
"QuestionPlaceholder": "Добавить вопрос",
"QuestionEmptyPlaceholder": "Введите текст вопроса",
"QuestionPlaceholderEmpty": "Введите текст вопроса",
"QuestionPlaceholderOption": "Добавить вариант",
"QuestionTooltipMandatory": "Ответ обязателен",
"QuestionTooltipCustomOption": "Пользователь может предложить свой вариант",
"Survey": "Анкета",
"Surveys": "Анкеты",
"SurveyEdit": "Редактировать форму",
"SurveyPreview": "Просмотр формы",
"SurveySubmit": "Отправить"
"SurveySubmit": "Завершить",
"SurveySubmitConfirm": "После этого вы больше не сможете изменять ответы. Вы уверены, что хотите завершить опрос сейчас?",
"ValidateFail": "Нет ответов на некоторые обязательные вопросы",
"ValidateInfo": "Так будет выглядеть форма для пользователя. Для проверки анкеты попробуйте вводить ответы и выбирать варианты. Зеленый значок в заголовке покажет, что форма заполнена правильно",
"ValidateOk": "Форма заполнена правильно"
}
}

View File

@ -19,13 +19,17 @@ import survey, { surveyId } from '@hcengineering/survey'
const icons = require('../assets/icons.svg') as string // eslint-disable-line
loadMetadata(survey.icon, {
Application: `${icons}#application`,
Info: `${icons}#info`,
Poll: `${icons}#page`,
Question: `${icons}#question`,
QuestionKindString: `${icons}#textline`,
QuestionKindOption: `${icons}#radio`,
QuestionKindOptions: `${icons}#checkbox`,
Survey: `${icons}#application`,
Submit: `${icons}#submit`,
QuestionIsMandatory: `${icons}#asterisk`,
QuestionHasCustomOption: `${icons}#star`
QuestionHasCustomOption: `${icons}#star`,
ValidateFail: `${icons}#validate-fail`,
ValidateOk: `${icons}#validate-ok`
})
addStringsLoader(surveyId, async (lang: string) => await import(`../lang/${lang}.json`))

View File

@ -0,0 +1,90 @@
<!--
//
// Copyright © 2024 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 { getClient } from '@hcengineering/presentation'
import { Question, QuestionKind, Poll, PollData } from '@hcengineering/survey'
import PollQuestion from './PollQuestion.svelte'
import { hasText } from '../utils'
const client = getClient()
export let object: Poll | PollData
export let canSubmit: boolean = false
export let readonly: boolean = false
const questionNodes: PollQuestion[] = []
const isAnswered: boolean[] = []
$: updateCanSubmit(isAnswered)
function updateCanSubmit (isAnswered: boolean[]): void {
canSubmit = isAnswered.every((yes) => yes)
}
function isQuestionValid (question: Question): boolean {
if (!hasText(question.name)) {
return false
}
if (question.kind === QuestionKind.OPTION || question.kind === QuestionKind.OPTIONS) {
if (question.options === undefined || question.options === null || question.options.length === 0) {
return false
}
}
return true
}
function isPreviewMode (): boolean {
return (object as Poll)._id === undefined
}
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 })
}
</script>
<div class="antiSection">
{#if hasText(object.prompt)}
<div class="antiSection-header">
<span class="antiSection-header__title">
{object.prompt}
</span>
</div>
{/if}
{#each object.questions ?? [] as question, index}
{#if isQuestionValid(question)}
<div class="question">
<PollQuestion
bind:this={questionNodes[index]}
bind:isAnswered={isAnswered[index]}
readonly={readonly || object.isCompleted}
on:answered={saveAnswers}
{question}
/>
</div>
{/if}
{/each}
</div>
<style lang="scss">
.question {
margin-top: 1.25em;
}
</style>

View File

@ -0,0 +1,114 @@
<!--
//
// Copyright © 2024 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 '@hcengineering/core'
import { Panel } from '@hcengineering/panel'
import { MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import { Poll } from '@hcengineering/survey'
import { Button, IconMoreH, showPopup } 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 survey from '../plugin'
const dispatch = createEventDispatcher()
const query = createQuery()
export let _id: Ref<Poll>
export let embedded: boolean = false
export let readonly: boolean = false
let object: Poll | undefined = undefined
let canSubmit = false
$: updateObject(_id)
function updateObject (_id: Ref<Poll>): void {
query.query(survey.class.Poll, { _id }, (result) => {
object = result[0]
})
}
async function submit (): Promise<void> {
if (object === undefined) {
return
}
showPopup(
MessageBox,
{
label: survey.string.SurveySubmit,
message: survey.string.SurveySubmitConfirm
},
undefined,
async (result?: boolean) => {
if (result === true && object !== undefined) {
await getClient().updateDoc(object._class, object.space, object._id, { isCompleted: true })
}
}
)
}
</script>
{#if object}
<Panel
isHeader={false}
isSub={false}
isAside={true}
{embedded}
{object}
on:open
on:close={() => {
dispatch('close')
}}
withoutInput={readonly}
>
<svelte:fragment slot="title">
{#if !embedded}<ParentsNavigator element={object} />{/if}
<DocNavLink noUnderline {object}>
<div class="title">{object.name}</div>
</DocNavLink>
</svelte:fragment>
<svelte:fragment slot="utils">
{#if !readonly}
{#if !(object.isCompleted ?? false)}
<Button
icon={survey.icon.Submit}
label={survey.string.SurveySubmit}
kind={'primary'}
disabled={!canSubmit}
showTooltip={{ label: canSubmit ? undefined : survey.string.ValidateFail }}
on:click={submit}
/>
{/if}
<Button
icon={IconMoreH}
iconProps={{ size: 'medium' }}
kind={'icon'}
on:click={(e) => {
showMenu(e, { object, excludedActions: [view.action.Open] })
}}
/>
{/if}
</svelte:fragment>
<div class="flex-col flex-grow flex-no-shrink">
<EditPoll {object} {readonly} bind:canSubmit />
</div>
</Panel>
{/if}

View File

@ -17,7 +17,16 @@
<script lang="ts">
import { MessageBox, getClient } from '@hcengineering/presentation'
import { Question, QuestionKind, Survey } from '@hcengineering/survey'
import { Button, EditBox, IconDelete, SelectPopup, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import {
Button,
EditBox,
Icon,
IconDelete,
SelectPopup,
eventToHTMLElement,
showPopup,
tooltip
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import survey from '../plugin'
@ -342,10 +351,20 @@
<div class="text">
<EditBox
disabled={readonly}
placeholder={survey.string.QuestionEmptyPlaceholder}
placeholder={survey.string.QuestionPlaceholderEmpty}
bind:value={question.name}
on:change={changeName}
/>
{#if question.hasCustomOption && question.kind !== QuestionKind.STRING}
<span class="question-param-icon" use:tooltip={{ label: survey.string.QuestionTooltipCustomOption }}>
<Icon icon={survey.icon.QuestionHasCustomOption} size="medium" fill="var(--theme-won-color)" />
</span>
{/if}
{#if question.isMandatory}
<span class="question-param-icon" use:tooltip={{ label: survey.string.QuestionTooltipMandatory }}>
<Icon icon={survey.icon.QuestionIsMandatory} size="medium" fill="var(--theme-urgent-color)" />
</span>
{/if}
</div>
{/if}
</div>
@ -390,7 +409,7 @@
<div class="text">
<EditBox
disabled={readonly}
placeholder={survey.string.QuestionOptionPlaceholder}
placeholder={survey.string.QuestionPlaceholderOption}
bind:value={options[index]}
on:change={async () => {
await changeOption(index)
@ -415,7 +434,7 @@
>
<Button noFocus={true} icon={survey.icon.Question} kind="list" size="small" />
<div class="text">
<EditBox placeholder={survey.string.QuestionOptionPlaceholder} bind:value={newOption} on:change={addOption} />
<EditBox placeholder={survey.string.QuestionPlaceholderOption} bind:value={newOption} on:change={addOption} />
</div>
</div>
{/if}
@ -443,6 +462,7 @@
margin-left: 1em;
margin-right: 1em;
flex-grow: 1;
display: flex;
}
.option {
display: flex;
@ -459,4 +479,7 @@
transition: box-shadow 0.1s ease-in;
box-shadow: 0 -3px 0 0 var(--primary-button-outline);
}
.question-param-icon {
margin-left: 0.5em;
}
</style>

View File

@ -17,9 +17,8 @@
<script lang="ts">
import { getClient } from '@hcengineering/presentation'
import { Survey } from '@hcengineering/survey'
import { Button, EditBox, FocusHandler, Label, createFocusManager, showPopup } from '@hcengineering/ui'
import { EditBox, FocusHandler, Label, createFocusManager } from '@hcengineering/ui'
import EditQuestion from './EditQuestion.svelte'
import SurveyForm from './SurveyForm.svelte'
import survey from '../plugin'
const manager = createFocusManager()
@ -27,7 +26,6 @@
export let object: Survey
export let readonly: boolean = false
export let showPeviewButton = true
$: questions = object?.questions ?? []
@ -39,10 +37,6 @@
await client.updateDoc(object._class, object.space, object._id, { prompt: object.prompt })
}
export function previewSurveyForm (): void {
showPopup(SurveyForm, { source: object }, 'top')
}
let draggedIndex: number | undefined = undefined
let draggedOverIndex: number | undefined = undefined
@ -96,9 +90,6 @@
<div class="flex-grow flex-col">
<div class="name">
<EditBox disabled={readonly} placeholder={survey.string.Name} bind:value={object.name} on:change={nameChange} />
{#if showPeviewButton}
<Button icon={survey.icon.Poll} label={survey.string.SurveyPreview} on:click={previewSurveyForm} />
{/if}
</div>
<div class="prompt">
<EditBox

View File

@ -19,12 +19,14 @@
import { Panel } from '@hcengineering/panel'
import { createQuery } from '@hcengineering/presentation'
import { Survey } from '@hcengineering/survey'
import { Button, IconMoreH } from '@hcengineering/ui'
import { Button, Icon, IconMoreH, Label, tooltip } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { DocNavLink, showMenu } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import EditSurvey from './EditSurvey.svelte'
import EditPoll from './EditPoll.svelte'
import survey from '../plugin'
import { makePollData } from '../utils'
const dispatch = createEventDispatcher()
const query = createQuery()
@ -34,9 +36,11 @@
export let readonly: boolean = false
let object: Survey | undefined = undefined
let editor: EditSurvey
let preview = false
let canSubmit = false
$: updateObject(_id)
$: poll = preview && object !== undefined ? makePollData(object) : undefined
function updateObject (_id: Ref<Survey>): void {
query.query(survey.class.Survey, { _id }, (result) => {
@ -65,14 +69,25 @@
</svelte:fragment>
<svelte:fragment slot="utils">
<Button
icon={survey.icon.Poll}
label={survey.string.SurveyPreview}
on:click={() => {
editor.previewSurveyForm()
}}
/>
{#if !readonly}
{#if preview}
{#if canSubmit}
<span use:tooltip={{ label: survey.string.ValidateOk }}>
<Icon size="x-large" icon={survey.icon.ValidateOk} fill="var(--theme-won-color)" />
</span>
{:else}
<span use:tooltip={{ label: survey.string.ValidateFail }}>
<Icon size="x-large" icon={survey.icon.ValidateFail} iconProps={{ opacity: 0.75 }} />
</span>
{/if}
{/if}
<Button
icon={preview ? survey.icon.Survey : survey.icon.Poll}
label={preview ? survey.string.SurveyEdit : survey.string.SurveyPreview}
on:click={() => {
preview = !preview
}}
/>
<Button
icon={IconMoreH}
iconProps={{ size: 'medium' }}
@ -85,7 +100,19 @@
</svelte:fragment>
<div class="flex-col flex-grow flex-no-shrink">
<EditSurvey bind:this={editor} {object} {readonly} showPeviewButton={false} />
{#if preview}
{#if poll !== undefined}
<div class="antiSection-empty solid flex-row mt-3">
<Icon icon={survey.icon.Info} size="large" />
<span class="content-dark-color" style="margin-left:1em">
<Label label={survey.string.ValidateInfo} />
</span>
</div>
<EditPoll object={poll} bind:canSubmit />
{/if}
{:else}
<EditSurvey {object} {readonly} />
{/if}
</div>
</Panel>
{/if}

View File

@ -16,13 +16,14 @@
-->
<script lang="ts">
import type { Class, Doc, Ref, Space } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Survey } from '@hcengineering/survey'
import { Button, IconAdd, Label, Section, showPopup } from '@hcengineering/ui'
import { Viewlet, ViewletPreference } from '@hcengineering/view'
import { Table, ViewletSelector, ViewletSettingButton } from '@hcengineering/view-resources'
import SurveyForm from './SurveyForm.svelte'
import { Button, IconAdd, Label, Section, navigate, showPopup } from '@hcengineering/ui'
import view, { Viewlet, ViewletPreference } from '@hcengineering/view'
import { Table, ViewletSelector, ViewletSettingButton, getObjectLinkFragment } from '@hcengineering/view-resources'
import SurveyPopup from './SurveyPopup.svelte'
import survey from '../plugin'
import { makePollData } from '../utils'
export let objectId: Ref<Doc>
export let space: Ref<Space>
@ -34,13 +35,29 @@
let viewlet: Viewlet | undefined
let preference: ViewletPreference | undefined
function createPoll (ev: MouseEvent): void {
showPopup(SurveyPopup, {}, ev.target as HTMLElement, (result) => {
function selectSurvey (ev: MouseEvent): void {
showPopup(SurveyPopup, {}, ev.target as HTMLElement, async (result) => {
if (result != null) {
showPopup(SurveyForm, { source: result as Survey, objectId, space, _class }, 'top')
await createPoll(result as Survey)
}
})
}
async function createPoll (source: Survey): Promise<void> {
const client = getClient()
const pollId = await client.addCollection(survey.class.Poll, space, objectId, _class, 'polls', makePollData(source))
const poll = await client.findOne(survey.class.Survey, { _id: pollId })
if (poll === undefined) {
console.error(`Could not find just created poll ${pollId}.`)
return
}
const hierarchy = client.getHierarchy()
const panel = hierarchy.classHierarchyMixin(poll._class as Ref<Class<Doc>>, view.mixin.ObjectPanel)
const loc = await getObjectLinkFragment(hierarchy, poll, {}, panel?.component)
navigate(loc)
}
</script>
<Section label={survey.string.Polls} icon={survey.icon.Poll}>
@ -55,7 +72,7 @@
/>
<ViewletSettingButton kind={'tertiary'} bind:viewlet />
{#if !readonly}
<Button id={survey.string.CreatePoll} icon={IconAdd} kind={'ghost'} on:click={createPoll} />
<Button id={survey.string.CreatePoll} icon={IconAdd} kind={'ghost'} on:click={selectSurvey} />
{/if}
</div>
</svelte:fragment>
@ -78,7 +95,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
{#if !readonly}
<span class="over-underline content-color" on:click={createPoll}>
<span class="over-underline content-color" on:click={selectSurvey}>
<Label label={survey.string.CreatePoll} />
</span>
{/if}

View File

@ -1,41 +0,0 @@
<!--
//
// Copyright © 2024 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 survey, { Poll } from '@hcengineering/survey'
import { Label, showPopup } from '@hcengineering/ui'
import PollResult from './PollResult.svelte'
import { hasText } from '../utils'
export let value: Poll | undefined | null
export let overflowLabel = true
function showPollResult (): void {
showPopup(PollResult, { poll: value })
}
</script>
{#if value}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span class="over-underline content-color" class:overflow-label={overflowLabel} on:click={showPollResult}>
{#if hasText(value.name)}
{value.name}
{:else}
<Label label={survey.string.NoName} />
{/if}
</span>
{/if}

View File

@ -0,0 +1,242 @@
<!--
//
// Copyright © 2024 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 { generateId } from '@hcengineering/core'
import { EditBox, Icon, Label, tooltip } from '@hcengineering/ui'
import { AnsweredQuestion, QuestionKind } from '@hcengineering/survey'
import { createEventDispatcher } from 'svelte'
import survey from '../plugin'
import { hasText } from '../utils'
const dispatch = createEventDispatcher()
export let question: AnsweredQuestion
export let isAnswered: boolean = false
export let readonly: boolean = false
let answer = ''
let selectedOption: number | undefined
const selectedOptions: boolean[] = []
const customOption = -1
$: id = generateId()
$: showAnswers(question)
$: updateIsAnswered(question, selectedOption, selectedOptions)
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
}
} else if (question.kind === QuestionKind.OPTIONS) {
question.answers?.forEach((index) => {
selectedOptions[index] = true
})
if (typeof question.answer === 'string') {
selectedOptions[customOption] = true
answer = question.answer
}
}
}
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)
}
})
if (selectedOptions[customOption]) {
answers.push(answer.trim())
}
return answers
}
return []
}
</script>
<div class="antiSection">
<div class="antiSection-header">
<span class="antiSection-header__title" style="display:flex">
{question.name}
{#if question.isMandatory && !readonly}
<span style="margin-left:0.25em" use:tooltip={{ label: survey.string.QuestionTooltipMandatory }}>
<Icon icon={survey.icon.QuestionIsMandatory} size="tiny" fill="var(--theme-urgent-color)" />
</span>
{/if}
</span>
</div>
{#if readonly}
{#each getReadonlyAnswers() as answer}
{#if answer}
<div class="answer">{answer}</div>
{:else}
<div class="answer empty">
<Label label={survey.string.NoAnswer} />
</div>
{/if}
{/each}
{:else if question.kind === QuestionKind.OPTION}
{#each question.options ?? [] as option, i}
<div class="option">
<input type="radio" id={`${id}-${i}`} value={i} bind:group={selectedOption} on:change={optionChange} />
<label class="option__label" for={`${id}-${i}`}>
{option}
</label>
</div>
{/each}
{#if question.hasCustomOption}
<div class="option">
<input
type="radio"
id={`${id}-custom`}
value={customOption}
bind:group={selectedOption}
on:change={optionChange}
/>
<label class="option__label" for={`${id}-custom`}>
<Label label={survey.string.AnswerCustomOption} />
</label>
{#if selectedOption === customOption}
<div class="option__custom">
<EditBox bind:value={answer} on:change={answerChange} placeholder={survey.string.AnswerPlaceholder} />
</div>
{/if}
</div>
{/if}
{:else if question.kind === QuestionKind.OPTIONS}
{#each question.options ?? [] as option, i}
<div class="option">
<input type="checkbox" id={`${id}-${i}`} bind:checked={selectedOptions[i]} on:change={optionsChange} />
<label class="option__label" for={`${id}-${i}`}>
{option}
</label>
</div>
{/each}
{#if question.hasCustomOption}
<div class="option">
<input
type="checkbox"
id={`${id}-custom`}
bind:checked={selectedOptions[customOption]}
on:change={optionsChange}
/>
<label class="option__label" for={`${id}-custom`}>
<Label label={survey.string.AnswerCustomOption} />
</label>
{#if selectedOptions[customOption]}
<div class="option__custom">
<EditBox bind:value={answer} on:change={answerChange} placeholder={survey.string.AnswerPlaceholder} />
</div>
{/if}
</div>
{/if}
{:else}
<div class="option">
<EditBox bind:value={answer} on:change={answerChange} placeholder={survey.string.AnswerPlaceholder} />
</div>
{/if}
</div>
<style lang="scss">
.option {
margin-left: 1em;
margin-top: 0.5em;
}
.option__label {
cursor: pointer;
margin-left: 0.25em;
}
.option__custom {
margin-left: 2em;
margin-top: 0.5em;
}
.answer {
margin-left: 2em;
margin-top: 0.5em;
&.empty {
opacity: 0.7;
}
}
</style>

View File

@ -1,100 +0,0 @@
<!--
//
// Copyright © 2024 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 { Poll } from '@hcengineering/survey'
import { FocusHandler, Label, createFocusManager } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import survey from '../plugin'
import { hasText } from '../utils'
const dispatch = createEventDispatcher()
const manager = createFocusManager()
export let poll: Poll
onMount(() => {
dispatch('open', {})
})
</script>
<FocusHandler {manager} />
<Card
label={survey.string.Survey}
canSave={true}
okLabel={survey.string.Close}
okAction={() => {
dispatch('close')
}}
on:close={() => {
dispatch('close')
}}
on:changeContent
>
<svelte:fragment slot="title">
{#if hasText(poll.name)}
{poll.name}
{:else}
<Label label={survey.string.NoName} />
{/if}
</svelte:fragment>
<div class="antiSection">
{#if hasText(poll.prompt)}
<div class="antiSection-header">
<span class="antiSection-header__title">
{poll.prompt}
</span>
</div>
{/if}
{#each poll.results ?? [] as result}
<div class="antiSection question">
<div class="antiSection-header">
<span class="antiSection-header__title">
{result.question}
</span>
</div>
{#if result.answer === undefined || result.answer === null || result.answer.length === 0}
<div class="answer empty">
<Label label={survey.string.NoAnswer} />
</div>
{:else}
{#each result.answer as answer}
<div class="answer">
{answer}
</div>
{/each}
{/if}
</div>
{/each}
</div>
</Card>
<style lang="scss">
.question {
margin-top: 1.25em;
}
.answer {
margin-left: 2em;
margin-top: 0.5em;
&.empty {
opacity: 0.7;
}
}
</style>

View File

@ -1,127 +0,0 @@
<!--
//
// Copyright © 2024 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 { Class, Doc, Ref, Space } from '@hcengineering/core'
import { Card, getClient } from '@hcengineering/presentation'
import { FocusHandler, Label, createFocusManager } from '@hcengineering/ui'
import { Question, QuestionKind, Survey } from '@hcengineering/survey'
import { createEventDispatcher, onMount } from 'svelte'
import SurveyFormQuestion from './SurveyFormQuestion.svelte'
import survey from '../plugin'
import { hasText } from '../utils'
const dispatch = createEventDispatcher()
const manager = createFocusManager()
export let source: Survey
export let objectId: Ref<Doc>
export let space: Ref<Space>
export let _class: Ref<Class<Doc>>
const questionNodes: SurveyFormQuestion[] = []
function isQuestionValid (question: Question): boolean {
if (!hasText(question.name)) {
return false
}
if (question.kind === QuestionKind.OPTION || question.kind === QuestionKind.OPTIONS) {
if (question.options === undefined || question.options.length === 0) {
return false
}
}
return true
}
function isPreview (): boolean {
return objectId === undefined || space === undefined || _class === undefined
}
function canSave (): boolean {
return source.questions !== undefined && source.questions.length > 0
// TODO: validate answers
}
async function saveAnswers (): Promise<void> {
if (!isPreview() && canSave()) {
await getClient().addCollection(survey.class.Poll, space, objectId, _class, 'polls', {
survey: source._id,
name: source.name,
prompt: source.prompt,
results: (source.questions ?? []).map((q, i) => {
let answer: string[] = []
const node = questionNodes[i]
if (node !== undefined) {
answer = node.getAnswer()
}
return {
question: q.name,
answer
}
})
})
}
dispatch('close')
}
onMount(() => {
dispatch('open', {})
})
</script>
<FocusHandler {manager} />
<Card
label={survey.string.Survey}
canSave={isPreview() || canSave()}
okLabel={isPreview() ? survey.string.Close : survey.string.SurveySubmit}
okAction={saveAnswers}
on:close={() => {
dispatch('close')
}}
on:changeContent
>
<svelte:fragment slot="title">
{#if hasText(source.name)}
{source.name}
{:else}
<Label label={survey.string.NoName} />
{/if}
</svelte:fragment>
<div class="antiSection">
{#if hasText(source.prompt)}
<div class="antiSection-header">
<span class="antiSection-header__title">
{source.prompt}
</span>
</div>
{/if}
{#each source.questions ?? [] as question, index}
{#if isQuestionValid(question)}
<div class="question">
<SurveyFormQuestion bind:this={questionNodes[index]} {question} />
</div>
{/if}
{/each}
</div>
</Card>
<style lang="scss">
.question {
margin-top: 1.25em;
}
</style>

View File

@ -1,132 +0,0 @@
<!--
//
// Copyright © 2024 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 { generateId } from '@hcengineering/core'
import { EditBox, Label } from '@hcengineering/ui'
import { Question, QuestionKind } from '@hcengineering/survey'
import survey from '../plugin'
export let question: Question
$: id = generateId()
let answer = ''
let selectedOption: number
const selectedOptions: Record<number, boolean> = {}
const customOption = -1
export function getAnswer (): string[] {
const answers = []
if (question.kind === QuestionKind.STRING) {
if (answer.trim().length > 0) {
answers.push(answer)
}
} else if (question.kind === QuestionKind.OPTION) {
if (question.options !== undefined && selectedOption !== undefined) {
if (selectedOption === customOption) {
answers.push(answer)
} else {
answers.push(question.options[selectedOption])
}
}
} else if (question.kind === QuestionKind.OPTIONS) {
if (question.options !== undefined) {
for (let i = 0; i < question.options.length; i++) {
if (selectedOptions[i]) {
answers.push(question.options[i])
}
}
}
if (selectedOptions[customOption]) {
answers.push(answer)
}
}
return answers
}
</script>
<div class="antiSection">
<div class="antiSection-header">
<span class="antiSection-header__title">
{question.name}
</span>
</div>
{#if question.kind === QuestionKind.OPTION}
{#each question.options ?? [] as option, i}
<div class="option">
<input type="radio" id={`${id}-${i}`} value={i} bind:group={selectedOption} />
<label class="option__label" for={`${id}-${i}`}>
{option}
</label>
</div>
{/each}
{#if question.hasCustomOption}
<div class="option">
<input id="custom" type="radio" value={customOption} bind:group={selectedOption} />
<label class="option__label" for="custom">
<Label label={survey.string.AnswerCustomOption} />
</label>
{#if selectedOption === customOption}
<div class="option__custom">
<EditBox bind:value={answer} placeholder={survey.string.AnswerPlaceholder} />
</div>
{/if}
</div>
{/if}
{:else if question.kind === QuestionKind.OPTIONS}
{#each question.options ?? [] as option, i}
<div class="option">
<input id={`${id}-${i}`} type="checkbox" bind:checked={selectedOptions[i]} />
<label class="option__label" for={`${id}-${i}`}>
{option}
</label>
</div>
{/each}
{#if question.hasCustomOption}
<div class="option">
<input id="custom" type="checkbox" bind:checked={selectedOptions[customOption]} />
<label class="option__label" for="custom">
<Label label={survey.string.AnswerCustomOption} />
</label>
{#if selectedOptions[customOption]}
<div class="option__custom">
<EditBox bind:value={answer} placeholder={survey.string.AnswerPlaceholder} />
</div>
{/if}
</div>
{/if}
{:else}
<div class="option">
<EditBox bind:value={answer} placeholder={survey.string.AnswerPlaceholder} />
</div>
{/if}
</div>
<style lang="scss">
.option {
margin-left: 1em;
margin-top: 0.5em;
}
.option__label {
cursor: pointer;
margin-left: 0.25em;
}
.option__custom {
margin-left: 2em;
margin-top: 0.5em;
}
</style>

View File

@ -15,13 +15,13 @@
//
-->
<script lang="ts">
import { getEmbeddedLabel } from '@hcengineering/platform'
import { DocNavLink, ObjectMention } from '@hcengineering/view-resources'
import { ObjectPresenterType } from '@hcengineering/view'
import { Icon, tooltip } from '@hcengineering/ui'
import survey, { Survey } from '@hcengineering/survey'
import { Icon, Label } from '@hcengineering/ui'
import survey, { Survey, Poll } from '@hcengineering/survey'
import { hasText } from '../utils'
export let value: Survey | undefined | null
export let value: Survey | Poll | undefined | null
export let inline: boolean = false
export let disabled: boolean = false
export let accent: boolean = false
@ -38,16 +38,26 @@
<ObjectMention object={value} {disabled} {accent} {noUnderline} {colorInherit} onClick={onEdit} />
{:else if type === 'link'}
<DocNavLink object={value} onClick={onEdit} {disabled} {noUnderline} {colorInherit} {accent} noOverflow>
<div class="flex-presenter" style:max-width={maxWidth} use:tooltip={{ label: getEmbeddedLabel(value.name) }}>
<div class="icon"><Icon icon={survey.icon.Survey} size={'small'} /></div>
<div class="flex-presenter" style:max-width={maxWidth}>
<div class="icon">
<Icon icon={value._class === survey.class.Survey ? survey.icon.Survey : survey.icon.Poll} size={'small'} />
</div>
<span class="ap-label" class:overflow-label={overflowLabel} class:colorInherit class:fs-bold={accent}>
{value.name}
{#if hasText(value.name)}
{value.name}
{:else}
<Label label={survey.string.NoName} />
{/if}
</span>
</div>
</DocNavLink>
{:else if type === 'text'}
<span class:overflow-label={overflowLabel} use:tooltip={{ label: getEmbeddedLabel(value.name) }}>
{value.name}
<span class:overflow-label={overflowLabel}>
{#if hasText(value.name)}
{value.name}
{:else}
<Label label={survey.string.NoName} />
{/if}
</span>
{/if}
{/if}

View File

@ -15,22 +15,41 @@
import { type Resources } from '@hcengineering/platform'
import CreateSurvey from './components/CreateSurvey.svelte'
import EditPollPanel from './components/EditPollPanel.svelte'
import EditSurveyPanel from './components/EditSurveyPanel.svelte'
import PollCollection from './components/PollCollection.svelte'
import PollPresenter from './components/PollPresenter.svelte'
import SurveyPresenter from './components/SurveyPresenter.svelte'
import { resolveLocation } from './utils'
import {
deletePoll,
getPollLink,
getSurveyLink,
pollTitleProvider,
resolveLocation,
surveyTitleProvider
} from './utils'
export * from './utils'
export default async (): Promise<Resources> => ({
component: {
CreateSurvey,
EditPollPanel,
EditSurveyPanel,
PollCollection,
PollPresenter,
PollPresenter: SurveyPresenter,
SurveyPresenter
},
resolver: {
Location: resolveLocation
},
function: {
GetPollLink: getPollLink,
GetSurveyLink: getSurveyLink,
PollTitleProvider: pollTitleProvider,
SurveyTitleProvider: surveyTitleProvider
},
actionImpl: {
DeletePoll: deletePoll
}
})

View File

@ -13,7 +13,29 @@
// limitations under the License.
//
import { mergeIds } from '@hcengineering/platform'
import type { Client, Doc, Ref } from '@hcengineering/core'
import { type Resource, mergeIds } from '@hcengineering/platform'
import survey, { surveyId } from '@hcengineering/survey'
import type { Location, ResolvedLocation } from '@hcengineering/ui'
import type { Action, ActionCategory, ViewAction } from '@hcengineering/view'
export default mergeIds(surveyId, survey, {})
export default mergeIds(surveyId, survey, {
resolver: {
Location: '' as Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
},
function: {
GetPollLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
GetSurveyLink: '' as Resource<(doc: Doc, props: Record<string, any>) => Promise<Location>>,
PollTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
SurveyTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
},
actionImpl: {
DeletePoll: '' as ViewAction
},
action: {
DeletePoll: '' as Ref<Action<Doc, any>>
},
category: {
Survey: '' as Ref<ActionCategory>
}
})

View File

@ -1,51 +1,172 @@
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { type Location, type ResolvedLocation, getPanelURI } from '@hcengineering/ui'
//
// Copyright © 2024 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.
//
import { clone, type Class, type Client, type Doc, type Ref } from '@hcengineering/core'
import survey, { surveyId, type Poll, type PollData, type Survey } from '@hcengineering/survey'
import { getClient, MessageBox } from '@hcengineering/presentation'
import {
type Location,
type ResolvedLocation,
getCurrentResolvedLocation,
getPanelURI,
showPopup
} from '@hcengineering/ui'
import { accessDeniedStore } from '@hcengineering/view-resources'
import view from '@hcengineering/view'
import survey, { surveyId, type Survey } from '@hcengineering/survey'
export function hasText (value: string | undefined | null): boolean {
return typeof value === 'string' && value.trim().length > 0
}
export async function generateLocation (loc: Location, id: Ref<Survey>): Promise<ResolvedLocation | undefined> {
const client = getClient()
export function makePollData (source: Survey): PollData {
return {
survey: source._id,
name: source.name,
prompt: source.prompt,
questions: clone(source.questions),
isCompleted: false
}
}
const surv = await client.findOne(survey.class.Survey, { _id: id })
if (surv === undefined) {
export async function generateSurveyLocation (loc: Location, id: Ref<Survey>): Promise<ResolvedLocation | undefined> {
const client = getClient()
const doc = await client.findOne(survey.class.Survey, { _id: id })
if (doc === undefined) {
accessDeniedStore.set(true)
console.error(`Could not find document ${id}.`)
console.error(`Could not find survey ${id}.`)
return undefined
}
const appComponent = loc.path[0] ?? ''
const workspace = loc.path[1] ?? ''
const objectPanel = client.getHierarchy().classHierarchyMixin(surv._class as Ref<Class<Doc>>, view.mixin.ObjectPanel)
const objectPanel = client.getHierarchy().classHierarchyMixin(doc._class as Ref<Class<Doc>>, view.mixin.ObjectPanel)
const component = objectPanel?.component ?? view.component.EditDoc
return {
loc: {
path: [appComponent, workspace, surveyId],
fragment: getPanelURI(component, surv._id, surv._class, 'content')
path: [appComponent, workspace],
fragment: getPanelURI(component, doc._id, doc._class, 'content')
},
defaultLocation: {
path: [appComponent, workspace, surveyId],
fragment: getPanelURI(component, surv._id, surv._class, 'content')
path: [appComponent, workspace, surveyId, 'surveys'],
fragment: getPanelURI(component, doc._id, doc._class, 'content')
}
}
}
export async function generatePollLocation (loc: Location, id: Ref<Poll>): Promise<ResolvedLocation | undefined> {
const client = getClient()
const doc = await client.findOne(survey.class.Poll, { _id: id })
if (doc === undefined) {
accessDeniedStore.set(true)
console.error(`Could not find poll ${id}.`)
return undefined
}
const appComponent = loc.path[0] ?? ''
const workspace = loc.path[1] ?? ''
const appId = loc.path[2] ?? ''
const objectPanel = client.getHierarchy().classHierarchyMixin(doc._class as Ref<Class<Doc>>, view.mixin.ObjectPanel)
const component = objectPanel?.component ?? view.component.EditDoc
return {
loc: {
path: [appComponent, workspace],
fragment: getPanelURI(component, doc._id, doc._class, 'content')
},
defaultLocation: {
path: [appComponent, workspace, appId],
fragment: getPanelURI(component, doc._id, doc._class, 'content')
}
}
}
export async function resolveLocation (loc: Location): Promise<ResolvedLocation | undefined> {
// if (loc.path[2] !== surveyId) {
// return undefined
// }
if (loc.path[2] !== surveyId) {
return undefined
}
// const id = loc.path[3] as Ref<Survey>
// if (id !== undefined) {
// return await generateLocation(loc, id)
// }
if (loc.path[3] === 'surveys') {
return undefined
}
return undefined
if (loc.path[3] === 'poll') {
return await generatePollLocation(loc, loc.path[4] as Ref<Poll>)
}
return await generateSurveyLocation(loc, loc.path[3] as Ref<Survey>)
}
export async function getSurveyLink (doc: Doc): Promise<Location> {
const loc = getCurrentResolvedLocation()
loc.path.length = 2
loc.fragment = undefined
loc.query = undefined
loc.path[2] = surveyId
loc.path[3] = doc._id
return loc
}
export async function getPollLink (doc: Doc): Promise<Location> {
const loc = getCurrentResolvedLocation()
loc.path.length = 3
loc.fragment = undefined
loc.query = undefined
// loc.path[2] is the app id from where the link is generated
loc.path[3] = 'poll'
loc.path[4] = doc._id
return loc
}
export async function pollTitleProvider (client: Client, ref: Ref<Doc>, doc?: Doc): Promise<string> {
const obj = await client.findOne(survey.class.Poll, { _id: ref as Ref<Poll> })
if (obj !== undefined && hasText(obj.name)) {
return obj.name
}
return ''
}
export async function surveyTitleProvider (client: Client, ref: Ref<Doc>, doc?: Doc): Promise<string> {
const obj = await client.findOne(survey.class.Survey, { _id: ref as Ref<Survey> })
if (obj !== undefined && hasText(obj.name)) {
return obj.name
}
return ''
}
export async function deletePoll (poll: Poll): Promise<void> {
showPopup(
MessageBox,
{
label: survey.string.DeletePoll,
message: survey.string.DeletePollConfirm
},
undefined,
async (result?: boolean) => {
if (result === true) {
await getClient().removeCollection(
poll._class,
poll.space,
poll._id,
poll.attachedTo,
poll.attachedToClass,
'polls'
)
}
}
)
}

View File

@ -14,9 +14,9 @@
//
import { Class, Doc, Ref, Space } from '@hcengineering/core'
import { plugin, IntlString, type Asset, type Plugin, type Resource } from '@hcengineering/platform'
import { plugin, IntlString, type Asset, type Plugin } from '@hcengineering/platform'
import { Viewlet } from '@hcengineering/view'
import { type AnyComponent, type ResolvedLocation } from '@hcengineering/ui'
import { AnyComponent } from '@hcengineering/ui'
import { Poll, Survey } from './types'
export * from './types'
@ -30,6 +30,7 @@ const survey = plugin(surveyId, {
},
icon: {
Application: '' as Asset,
Info: '' as Asset,
Poll: '' as Asset,
Question: '' as Asset,
QuestionKindString: '' as Asset,
@ -37,7 +38,10 @@ const survey = plugin(surveyId, {
QuestionKindOptions: '' as Asset,
QuestionIsMandatory: '' as Asset,
QuestionHasCustomOption: '' as Asset,
Survey: '' as Asset
Submit: '' as Asset,
Survey: '' as Asset,
ValidateOk: '' as Asset,
ValidateFail: '' as Asset
},
space: {
Survey: '' as Ref<Space>
@ -52,9 +56,12 @@ const survey = plugin(surveyId, {
Application: '' as IntlString,
Close: '' as IntlString,
Control: '' as IntlString,
Completed: '' as IntlString,
CreatePoll: '' as IntlString,
CreateSurvey: '' as IntlString,
DeleteOption: '' as IntlString,
DeletePoll: '' as IntlString,
DeletePollConfirm: '' as IntlString,
DeleteQuestion: '' as IntlString,
DeleteQuestionConfirm: '' as IntlString,
Name: '' as IntlString,
@ -75,16 +82,24 @@ const survey = plugin(surveyId, {
QuestionIsMandatory: '' as IntlString,
QuestionHasCustomOption: '' as IntlString,
QuestionOptions: '' as IntlString,
QuestionOptionPlaceholder: '' as IntlString,
QuestionPlaceholder: '' as IntlString,
QuestionEmptyPlaceholder: '' as IntlString,
QuestionPlaceholderEmpty: '' as IntlString,
QuestionPlaceholderOption: '' as IntlString,
QuestionTooltipMandatory: '' as IntlString,
QuestionTooltipCustomOption: '' as IntlString,
Survey: '' as IntlString,
Surveys: '' as IntlString,
SurveyEdit: '' as IntlString,
SurveyPreview: '' as IntlString,
SurveySubmit: '' as IntlString
SurveySubmit: '' as IntlString,
SurveySubmitConfirm: '' as IntlString,
ValidateFail: '' as IntlString,
ValidateInfo: '' as IntlString,
ValidateOk: '' as IntlString
},
component: {
CreateSurvey: '' as AnyComponent,
EditPollPanel: '' as AnyComponent,
EditSurveyPanel: '' as AnyComponent,
PollCollection: '' as AnyComponent,
PollPresenter: '' as AnyComponent,
@ -93,9 +108,6 @@ const survey = plugin(surveyId, {
viewlet: {
TableSurvey: '' as Ref<Viewlet>,
TablePoll: '' as Ref<Viewlet>
},
resolver: {
Location: '' as Resource<(loc: Location) => Promise<ResolvedLocation | undefined>>
}
})

View File

@ -38,10 +38,19 @@ export interface Question {
hasCustomOption: boolean
}
export interface AnsweredQuestion extends Question {
answer?: string
answers?: number[]
}
/** @public */
export interface Poll extends AttachedDoc {
export interface PollData {
survey: Ref<Survey>
name: string
prompt: string
results?: { question: string, answer: string[] }[]
questions?: AnsweredQuestion[]
isCompleted?: boolean
}
/** @public */
export interface Poll extends PollData, AttachedDoc {}