Support for rating system (#10124)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2025-10-24 10:10:01 +07:00
committed by GitHub
parent 768ff39ab1
commit ffd8d90378
137 changed files with 5005 additions and 712 deletions
@@ -0,0 +1,15 @@
<script lang="ts">
import ratingPlugin, { DocReaction, ReactionKind } from '@hcengineering/rating'
import { Icon } from '@hcengineering/ui'
import ReactionPresenter from './ReactionPresenter.svelte'
export let value: DocReaction | null = null
</script>
{#if value}
{#if value.reactionType === ReactionKind.Star}
<Icon icon={ratingPlugin.icon.StarYellow} size={'small'} />
{:else}
<ReactionPresenter emoji={value?.emoji ?? ''} />
{/if}
{/if}
@@ -0,0 +1,53 @@
<!--
// Copyright © 2025 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, type Class, type Doc } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { FixedColumn, ObjectPresenter } from '@hcengineering/view-resources'
import RatingEditor from './RatingEditor.svelte'
import type { DocReaction } from '@hcengineering/rating'
export let _class: Class<Doc>
export let docs: { _id: Ref<Doc>, reactions: DocReaction[] }[] = []
const query = createQuery()
const limit: number = 20
let objects: { doc: Doc, reactions: DocReaction[] }[] = []
$: query.query(
_class._id,
{ _id: { $in: docs.map((it) => it._id) } },
(res) => {
objects = res.map((doc) => ({
doc,
reactions: docs.find((it) => it._id === doc._id)?.reactions ?? []
}))
},
{ limit, total: true }
)
</script>
<div class="ml-2 flex-col">
{#each objects as doc (doc.doc._id)}
<div class="flex-presenter p-1 flex flex-between">
<FixedColumn key={'reactions_link'}>
<ObjectPresenter _class={doc.doc._class} objectId={doc.doc._id} value={doc.doc} noUnderline />
</FixedColumn>
<RatingEditor _class={doc.doc._class} _id={doc.doc._id} reactions={doc.reactions} showMy={false} />
</div>
{/each}
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { PersonRating } from '@hcengineering/rating'
export let rating: PersonRating | undefined
// Sorted months
$: months = [...(rating?.months ?? [])].sort((a, b) => a[0] - b[0])
$: fullYears = Array.from(new Set(months.map((it) => Math.floor(it[0] / 100))))
$: ymin = Math.min(...fullYears)
$: ymax = Math.max(...fullYears)
const monthMax: number = 500
// Color scale: 0 = #ebedf0, low = #c6e48b, mid = #7bc96f, high = #239a3b, max = #196127
function getColor (val: number, max: number): string {
max = Math.max(max, monthMax)
const COLORS = [
'transparent',
'#c6e48b',
'#a0d16e',
'#8ec760',
'#7bc96f',
'#6bb85e',
'#5ba94d',
'#4b9a3c',
'#3b8b2b',
'#2b7c1a',
'#239a3b',
'#1f7a2c',
'#1b7820',
'#177614',
'#156c11',
'#196127',
'#144f0e'
]
if (val <= 0) return COLORS[0]
const idx = 1 + Math.min(COLORS.length - 2, Math.floor((val / max) * (COLORS.length - 1)))
return COLORS[idx]
}
function getMax (year: number, rating?: PersonRating): number {
const monthData = rating?.months?.filter((it) => Math.floor(it[0] / 100) === year)
if (monthData === undefined) return monthMax
return Math.max(...monthData.map((val) => (val[1] ?? 0) + (val[2] ?? 0) + (val[3] ?? 0)))
}
import ratingPlugin from '@hcengineering/rating'
import { tooltip } from '@hcengineering/ui'
// Formatter for localized month names (short form, e.g. 'Jan', 'Feb' or localized equivalent)
const monthFormatter = new Intl.DateTimeFormat(undefined, { month: 'short' })
</script>
{#if rating}
<div class="flex flex-col">
<div class="rating-activities">
<!-- Monthly grid -->
{#each Array.from({ length: ymax - ymin + 1 }, (_, i) => i + ymin) as year}
{@const ymonths = months.filter((it) => Math.floor(it[0] / 100) === year)}
<div class="flex-row flex flex-col p-1">
<div class="flex flex-grow justify-center">
{year}
</div>
<div class="contribution-grid">
{#each Array.from({ length: 12 }, (_, i) => i) as m, i}
{@const monthval = year * 100 + m}
{@const yval = ymonths.find((it) => it[0] === monthval)}
{#if yval}
{@const ops = (yval[1] ?? 0) + (yval[2] ?? 0) + (yval[3] ?? 0)}
{@const monthLabel = monthFormatter.format(new Date(year, i, 1))}
<div
class="contribution-cell"
use:tooltip={{ label: ratingPlugin.string.MonthOps, props: { month: monthLabel, ops: String(ops) } }}
style="background: {getColor(ops, getMax(year, rating))}"
></div>
{:else}
{@const monthLabel = monthFormatter.format(new Date(year, i, 1))}
<div
class="contribution-cell"
use:tooltip={{ label: ratingPlugin.string.MonthNoOps, props: { month: monthLabel } }}
style="background: {getColor(0, monthMax)}"
></div>
{/if}
{/each}
</div>
</div>
{/each}
</div>
</div>
{/if}
<style>
.rating-activities {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 1em;
max-width: fit-content;
max-height: fit-content;
}
.contribution-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
margin-bottom: 1em;
max-width: fit-content;
max-height: fit-content;
}
.contribution-cell {
width: 100%;
height: 10px;
width: 10px;
aspect-ratio: 1/1;
border-radius: 2px;
border: 1px solid #e0e0e0;
transition: background 0.2s;
}
</style>
@@ -0,0 +1,259 @@
<script lang="ts">
import type { Class, Doc, Ref } from '@hcengineering/core'
import core, { getCurrentAccount, groupByArray } from '@hcengineering/core'
import emojiPlugin from '@hcengineering/emoji'
import { createQuery, getClient } from '@hcengineering/presentation'
import type { DocReaction } from '@hcengineering/rating'
import ratingPlugin, { ReactionKind } from '@hcengineering/rating'
import { Icon, showPopup, tooltip } from '@hcengineering/ui'
import ReactionPresenter from './ReactionPresenter.svelte'
import { translateCB } from '@hcengineering/platform'
export let _id: Ref<Doc>
export let _class: Ref<Class<Doc>>
export let showMy = true
export let reactions: DocReaction[] = []
let _reactions = reactions
// Compute reaction groups
$: starReactions = _reactions.filter((r) => r.reactionType === ReactionKind.Star)
$: otherReactions = groupByArray(
_reactions.filter((r) => r.reactionType === ReactionKind.Emoji),
(it) => it.emoji ?? ''
).entries()
$: starCount = starReactions.filter((it) => it.value === 1).length
const account = getCurrentAccount()
const socialIds = new Set(account.fullSocialIds.map((it) => it._id))
const client = getClient()
const reactionsQuery = createQuery()
$: if (reactions.length === 0) {
reactionsQuery.query(ratingPlugin.class.DocReaction, { attachedTo: _id, attachedToClass: _class }, (result) => {
_reactions = result
})
} else {
reactionsQuery.unsubscribe()
_reactions = reactions
}
async function addStarReaction (): Promise<void> {
const existing = _reactions.filter((it) => it.reactionType === ReactionKind.Star && socialIds.has(it.modifiedBy))
if (existing.length > 0) {
for (const e of existing) {
if (e.value === 0) {
await client.update(e, { value: 1 })
} else {
await client.update(e, { value: 0 })
}
}
} else {
await client.addCollection(ratingPlugin.class.DocReaction, core.space.Workspace, _id, _class, '_reactions', {
reactionType: ReactionKind.Star,
value: 1
})
}
}
async function addEmojiReaction (event: MouseEvent): Promise<void> {
showPopup(emojiPlugin.component.EmojiPopup, {}, event.target as HTMLElement, async (emoji) => {
if (emoji?.text === undefined) return
const existing = _reactions.filter(
(it) => it.reactionType === ReactionKind.Emoji && it.emoji === emoji.text && socialIds.has(it.modifiedBy)
)
if (existing.length > 0) {
for (const e of existing) {
await client.remove(e)
}
} else {
await client.addCollection(ratingPlugin.class.DocReaction, core.space.Workspace, _id, _class, '_reactions', {
reactionType: ReactionKind.Emoji,
value: 0,
emoji: emoji.text,
image: emoji.image
})
}
})
}
async function removeReaction (reactions: DocReaction[]): Promise<void> {
for (const reaction of reactions) {
if (account.fullSocialIds.some((it) => it._id === reaction.modifiedBy)) {
await client.removeDoc(reaction._class, reaction.space, reaction._id)
}
}
}
$: hasSelectedStar = _reactions.some(
(it) => it.reactionType === ReactionKind.Star && socialIds.has(it.createdBy ?? it.modifiedBy) && it.value === 1
)
let addStarAria: string | undefined = undefined
$: translateCB(ratingPlugin.string.AddStarAria, {}, undefined, (r) => (addStarAria = r))
</script>
<div class="rating-editor">
<!-- Inline Reactions -->
<div class="reactions-container">
<!-- Star Reactions (Premium) -->
<button
class="star-reaction"
on:click={addStarReaction}
use:tooltip={{ label: ratingPlugin.string.AddStar }}
aria-label={addStarAria}
>
<span class="star-icon">
<Icon icon={hasSelectedStar ? ratingPlugin.icon.StarYellow : ratingPlugin.icon.Rating} size={'small'} />
</span>
{#if showMy && starCount > 0}
<span class="star-count">{starCount}</span>
{/if}
</button>
<!-- Other Emoji Reactions -->
{#each otherReactions as oreact (oreact[0])}
{@const emoji = oreact[1][0].emoji}
{@const data = oreact[1]}
<ReactionPresenter
{emoji}
selected={showMy && data.some((it) => socialIds.has(it.modifiedBy))}
socialIds={data.map((it) => it.modifiedBy)}
count={showMy ? data.length : 0}
on:click={() => removeReaction(oreact[1])}
/>
{/each}
<!-- Add Emoji Reaction Button -->
<ReactionPresenter icon={emojiPlugin.icon.EmojiAdd} iconSize="small" active={true} on:click={addEmojiReaction} />
</div>
</div>
<style lang="scss">
.rating-editor {
display: flex;
align-items: center;
gap: 0.15rem;
padding: 0.1rem;
background: transparent;
border-radius: 0;
border: none;
}
.reactions-container {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: nowrap;
}
.reaction-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: transparent;
border: 1px solid var(--button-secondary-BorderColor);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
font-size: 0.9rem;
flex-shrink: 0;
min-height: 1.5rem;
gap: 0.25rem;
&:hover {
background: var(--button-secondary-hover-BackgroundColor);
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
.star-reaction {
@extend .reaction-btn;
}
.star-icon {
display: flex;
align-items: center;
justify-content: center;
}
.star-count {
font-size: 0.65rem;
font-weight: 600;
color: var(--global-primary-TextColor);
min-width: 0.75rem;
text-align: center;
}
.reaction-item {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
&:hover {
transform: scale(1.15);
}
}
.reaction-emoji {
font-size: 0.9rem;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
.reaction-remove {
position: absolute;
top: -0.35rem;
right: -0.35rem;
width: 0.9rem;
height: 0.9rem;
padding: 0;
background: #ff4444;
color: white;
border: none;
border-radius: 50%;
font-size: 0.5rem;
font-weight: bold;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.1s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
&:hover {
transform: scale(1.15);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
}
&:active {
transform: scale(0.9);
}
}
.add-reaction-btn {
@extend .reaction-btn;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border-radius: 50%;
}
</style>
@@ -0,0 +1,94 @@
<!-- Circular Level Display with Progress -->
<script lang="ts">
import { translateCB } from '@hcengineering/platform'
import ratingPlugin, { getLevelInfo } from '@hcengineering/rating'
export let rating: number = 0
export let showValues = false
$: levelInfo = getLevelInfo(rating)
let levelLabel: string | undefined = undefined
$: translateCB(ratingPlugin.string.Level, {}, undefined, (r) => (levelLabel = r))
</script>
{#if rating > 0}
<div class="flex-row-center">
{#if levelLabel}{levelLabel}{/if}
<div class="level-circle">
<svg class="progress-ring" viewBox="0 0 120 120" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="60" cy="60" r="50" class="progress-ring-bg" />
<!-- Progress circle (bold overlay) -->
<circle
cx="60"
cy="60"
r="50"
class="progress-ring-fill"
style="stroke-dashoffset: {314.16 * (1 - levelInfo.progress / 100)}px"
/>
</svg>
<div class="level-content">
<span class="level-number">{levelInfo.level}</span>
</div>
</div>
{#if showValues}
<span class="text-sm caption-color">({Math.round(rating * 100)}/{Math.round(levelInfo.nextThreshold * 100)})</span
>
{/if}
</div>
{/if}
<style lang="scss">
.level-circle {
position: relative;
width: 2rem;
height: 2rem;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.progress-ring {
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.progress-ring-bg {
fill: none;
stroke: var(--global-secondary-TextColor);
stroke-width: 1.5;
opacity: 0.15;
}
.progress-ring-fill {
fill: none;
stroke: #ffc107;
stroke-width: 3.5;
stroke-linecap: round;
transition: stroke-dashoffset 0.3s ease;
stroke-dasharray: 314.16;
&:hover {
filter: drop-shadow(0 0 0.2rem rgba(255, 193, 7, 0.7));
}
}
.level-content {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
}
.level-number {
font-size: 0.9rem;
font-weight: 700;
color: var(--global-primary-TextColor);
line-height: 1;
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import { getCurrentAccount, groupByArray, SortingOrder, type Class, type Doc, type Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import rating, { ReactionKind, type DocReaction } from '@hcengineering/rating'
import { NavGroup, Label } from '@hcengineering/ui'
import ScrollBox from '@hcengineering/ui/src/components/ScrollBox.svelte'
import NavigatorRating from './NavigatorRating.svelte'
const current = getCurrentAccount()
const query = createQuery()
let reactions: DocReaction[] = []
query.query(
rating.class.DocReaction,
{
createdBy: { $in: current.socialIds }
},
(results) => {
reactions = results
},
{ limit: 5000, sort: { modifiedOn: SortingOrder.Descending } }
)
function groupReactions (reactions: DocReaction[]): {
_class: Ref<Class<Doc>>
objects: {
_id: Ref<Doc>
reactions: DocReaction[]
}[]
}[] {
const starred = new Set(
reactions.filter((it) => it.reactionType === ReactionKind.Star && it.value === 1).map((it) => it.attachedTo)
)
const validReactions = reactions.filter(
(it) => it.attachedTo != null && it.attachedToClass != null && starred.has(it.attachedTo)
)
// I need to filter reactions without star's
const byBaseClass = groupByArray(validReactions, (it) => it.attachedToClass)
return Array.from(byBaseClass.entries()).map(([baseClass, reactions]) => {
const byDoc = Array.from(groupByArray(reactions, (it) => it.attachedTo)).map((it) => ({
_id: it[0],
reactions: it[1]
}))
return {
_class: baseClass,
objects: byDoc
}
})
}
$: groupped = groupReactions(reactions)
$: isEmpty = groupped.length === 0 || groupped.every((g) => g.objects.length === 0)
</script>
<div class="mt-2" />
{#if isEmpty}
<div class="p-4 text-center content-dark-color h-full w-full flex-grow" style:align-content="center">
<Label label={rating.string.RatingWidgetEmpty} />
</div>
{:else}
<ScrollBox vertical>
{#each groupped as group}
{@const classifier = getClient().getHierarchy().findClass(group._class)}
{#if classifier !== undefined}
<NavGroup
_id={group._class}
categoryName={'features'}
label={classifier.label}
type="nested"
isFold
empty={group.objects.length === 0}
noDivider
>
<div class="ml-2 mr-2">
<NavigatorRating _class={classifier} docs={group.objects} />
</div>
</NavGroup>
{/if}
{/each}
</ScrollBox>
{/if}
@@ -0,0 +1,84 @@
<!-- Copyright © 2025 Hardcore Engineering Inc. -->
<!-- -->
<!-- Licensed under the Eclipse Public License, Version 2.0 (the "License"); -->
<!-- you may not use this file except in compliance with the License. You may -->
<!-- obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 -->
<!-- -->
<!-- Unless required by applicable law or agreed to in writing, software -->
<!-- distributed under the License is distributed on an "AS IS" BASIS, -->
<!-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -->
<!-- -->
<!-- See the License for the specific language governing permissions and -->
<!-- limitations under the License. -->
<script lang="ts">
import { type PersonId } from '@hcengineering/core'
import { EmojiPresenter } from '@hcengineering/emoji-resources'
import { Icon, IconComponent, IconSize, tooltip } from '@hcengineering/ui'
import ReactionsTooltip from './ReactionsTooltip.svelte'
export let icon: IconComponent | undefined = undefined
export let iconSize: IconSize | undefined = undefined
export let emoji: string = ''
export let count: number | undefined = undefined
export let selected: boolean = false
export let active: boolean = false
export let socialIds: PersonId[] = []
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="reaction"
class:selected
class:active
on:click
use:tooltip={(count ?? 0) > 0 ? { component: ReactionsTooltip, props: { socialIds, emoji } } : undefined}
>
<div class="reaction__emoji" class:foreground={icon != null}>
{#if icon}
<Icon {icon} size={iconSize ?? 'small'} />
{:else}
<EmojiPresenter {emoji} />
{/if}
</div>
{#if count !== undefined && count > 0}
<div class="reaction__count">{count}</div>
{/if}
</div>
<style lang="scss">
.reaction {
display: flex;
height: 1.75rem;
padding: 0.375rem;
align-items: center;
gap: 0.25rem;
border-radius: 0.5rem;
background: var(--selector-BackgroundColor);
cursor: pointer;
&:hover,
&.active {
background: var(--global-ui-hover-BackgroundColor);
}
&.selected {
background: var(--global-accent-BackgroundColor);
}
}
.reaction__emoji {
color: var(--global-primary-TextColor);
font-size: 1rem;
font-weight: 400;
line-height: 1rem;
}
.reaction__count {
color: var(--global-primary-TextColor);
font-size: 0.75rem;
font-weight: 500;
}
</style>
@@ -0,0 +1,17 @@
<script lang="ts">
import { PersonId } from '@hcengineering/core'
import { ObjectPresenter } from '@hcengineering/view-resources'
import contact from '@hcengineering/contact'
import { getPersonRefByPersonIdStore } from '@hcengineering/contact-resources'
export let socialIds: PersonId[] = []
$: personRefByPersonIdStore = getPersonRefByPersonIdStore(socialIds)
$: persons = socialIds.map((si) => $personRefByPersonIdStore.get(si))
</script>
<div class="m-2 flex-col flex-gap-2">
{#each persons as person}
<ObjectPresenter objectId={person} _class={contact.class.Person} disabled />
{/each}
</div>
+29
View File
@@ -0,0 +1,29 @@
// Copyright © 2025 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 { type Resources } from '@hcengineering/platform'
import RatingEditor from './components/RatingEditor.svelte'
import RatingRing from './components/RatingRing.svelte'
import RatingWidget from './components/RatingWidget.svelte'
import DocReactionPresenter from './components/DocReactionPresenter.svelte'
import RatingActivities from './components/RatingActivities.svelte'
export default async (): Promise<Resources> => ({
component: {
RatingEditor,
RatingRing,
RatingWidget,
DocReactionPresenter,
RatingActivities
}
})
+21
View File
@@ -0,0 +1,21 @@
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import { mergeIds } from '@hcengineering/platform'
import rating, { ratingId } from '@hcengineering/rating'
import { type AnyComponent } from '@hcengineering/ui/src/types'
export default mergeIds(ratingId, rating, {
component: {
RatingEditor: '' as AnyComponent
}
})