Tag categories initial (#1030)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2022-02-21 16:11:31 +07:00
committed by GitHub
parent 2fd7e3a94f
commit 6748b60eca
34 changed files with 618 additions and 79 deletions
+15 -3
View File
@@ -1,6 +1,7 @@
lockfileVersion: 5.3
specifiers:
'@anticrm/skillset': https://github.com/hcengineering/skillset.git
'@elastic/elasticsearch': ^7.14.0
'@koa/cors': ^3.1.0
'@microsoft/api-extractor': ^7.18.4
@@ -210,6 +211,7 @@ specifiers:
xml2js: ~0.4.23
dependencies:
'@anticrm/skillset': github.com/hcengineering/skillset/7c97534c09c7382546f10d16ae27721a5704018f
'@elastic/elasticsearch': 7.16.0
'@koa/cors': 3.1.0
'@microsoft/api-extractor': 7.19.2
@@ -12597,11 +12599,12 @@ packages:
dev: false
file:projects/model-tags.tgz_typescript@4.5.4:
resolution: {integrity: sha512-M+xHdfGituHUoH7YgkHc5lw2TXsvV3jvf3g73EpjJgGl4NFclNd1VkBtyEQ9Czsjy3N/jff1B2FePFu7u2p6FA==, tarball: file:projects/model-tags.tgz}
resolution: {integrity: sha512-ydbpF6SS1lFreF2Dyb7mHeKUD5ajzVa/KhOSiAMiVCU9zwy7aoFfUE3Uy+98biYE+qv/6jsF9x93o3r5WfYEfg==, tarball: file:projects/model-tags.tgz}
id: file:projects/model-tags.tgz
name: '@rush-temp/model-tags'
version: 0.0.0
dependencies:
'@anticrm/skillset': github.com/hcengineering/skillset/7c97534c09c7382546f10d16ae27721a5704018f
'@rushstack/heft': 0.41.8
'@types/heft-jest': 1.0.2
'@typescript-eslint/eslint-plugin': 5.7.0_c25e8c1f4f4f7aaed27aa6f9ce042237
@@ -12744,7 +12747,7 @@ packages:
dev: false
file:projects/model.tgz:
resolution: {integrity: sha512-7sg1S1NADyRy2HZFRHyYjlq55h31ArIg5XVIo4Ydm9NfmOObk0gocDHP/6cP/7Y+vgvS2JlUZTTTTGQ35qG0XA==, tarball: file:projects/model.tgz}
resolution: {integrity: sha512-1k5LE7nXCQ1TvWCYoxozAUY0TJXD/l2z4QbObZl27O0O0HFPhQsu1kcjsC5zlfK1PDNIAYbG4msK5ka9E0LIYg==, tarball: file:projects/model.tgz}
name: '@rush-temp/model'
version: 0.0.0
dependencies:
@@ -12758,6 +12761,7 @@ packages:
eslint-plugin-import: 2.25.3_eslint@7.32.0
eslint-plugin-node: 11.1.0_eslint@7.32.0
eslint-plugin-promise: 5.2.0_eslint@7.32.0
fast-equals: 2.0.4
prettier: 2.5.1
toposort: 2.0.2
typescript: 4.5.4
@@ -13581,7 +13585,7 @@ packages:
dev: false
file:projects/tags.tgz:
resolution: {integrity: sha512-dDZIEx6iNmqyXw6CrYnmGL1PV3li1Tjuc0OSYr00PKg6UsvhrX3ah6J6W9jHm7tjL25ccMkhrVjpWQQOJsC6LQ==, tarball: file:projects/tags.tgz}
resolution: {integrity: sha512-JUARXu0wQ++Y0afKL5DLKcEQUgTjdC4gy/HZR+KLyQ6oNBJXDW/HYGVF/X012K4V2qa3LDcNNMIBrgRggfAGHg==, tarball: file:projects/tags.tgz}
name: '@rush-temp/tags'
version: 0.0.0
dependencies:
@@ -13595,6 +13599,7 @@ packages:
eslint-plugin-node: 11.1.0_eslint@7.32.0
eslint-plugin-promise: 5.2.0_eslint@7.32.0
prettier: 2.5.1
svelte: 3.44.3
typescript: 4.5.4
transitivePeerDependencies:
- supports-color
@@ -14164,3 +14169,10 @@ packages:
transitivePeerDependencies:
- supports-color
dev: false
github.com/hcengineering/skillset/7c97534c09c7382546f10d16ae27721a5704018f:
resolution: {tarball: https://codeload.github.com/hcengineering/skillset/tar.gz/7c97534c09c7382546f10d16ae27721a5704018f}
name: '@anticrm/skillset'
version: 0.6.0
requiresBuild: true
dev: false
+1 -1
View File
@@ -16,5 +16,5 @@
#
rushx bundle
rushx docker:build
rushx docker:build $@
rushx docker:push
+3 -1
View File
@@ -23,6 +23,7 @@ import { leadOperation } from '@anticrm/model-lead'
import { recruitOperation } from '@anticrm/model-recruit'
import { viewOperation } from '@anticrm/model-view'
import { contactOperation } from '@anticrm/model-contact'
import { tagsOperation } from '@anticrm/model-tags'
export const migrateOperations: MigrateOperation[] = [
coreOperation,
@@ -31,5 +32,6 @@ export const migrateOperations: MigrateOperation[] = [
leadOperation,
recruitOperation,
viewOperation,
contactOperation
contactOperation,
tagsOperation
]
+1 -1
View File
@@ -208,7 +208,7 @@ export function createModel (builder: Builder): void {
// key: '$lookup.skills', // Required, since presenter require list of tag references or '' and TagsPopupPresenter
presenter: tags.component.TagsPresenter, // tags.component.TagsPresenter,
label: recruit.string.SkillsLabel,
// sortingKey: '$lookup.skills',
sortingKey: 'skills',
props: {
_class: recruit.mixin.Candidate,
key: 'skills'
+2 -1
View File
@@ -33,6 +33,7 @@
"@anticrm/tags-resources": "~0.6.0",
"@anticrm/ui": "~0.6.0",
"@anticrm/model-core": "~0.6.0",
"@anticrm/model-view": "~0.6.0"
"@anticrm/model-view": "~0.6.0",
"@anticrm/skillset": "https://github.com/hcengineering/skillset.git"
}
}
+26 -5
View File
@@ -13,13 +13,14 @@
// limitations under the License.
import { Class, Doc, Domain, IndexKind, Ref } from '@anticrm/core'
import { Builder, Index, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
import { Builder, Collection, Index, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model'
import core, { TAttachedDoc, TDoc } from '@anticrm/model-core'
import view from '@anticrm/model-view'
import type { TagElement, TagReference } from '@anticrm/tags'
import { ObjectDDParticipant } from '../../view/node_modules/@anticrm/view/lib'
import view, { ObjectDDParticipant } from '@anticrm/model-view'
import { Asset, IntlString } from '@anticrm/platform'
import type { TagCategory, TagElement, TagReference } from '@anticrm/tags'
import tags from './plugin'
export { tagsOperation } from './migration'
export { tags as default }
export const DOMAIN_TAGS = 'tags' as Domain
@@ -40,6 +41,9 @@ export class TTagElement extends TDoc implements TagElement {
@Prop(TypeString(), tags.string.ColorLabel)
color!: number
@Prop(TypeRef(tags.class.TagCategory), tags.string.CategoryLabel)
category!: Ref<TagCategory>
}
@Model(tags.class.TagReference, core.class.AttachedDoc, DOMAIN_TAGS)
@@ -50,14 +54,31 @@ export class TTagReference extends TAttachedDoc implements TagReference {
title!: string
@Prop(TypeRef(tags.class.TagElement), tags.string.TagLabel)
@Index(IndexKind.Indexed)
tag!: Ref<TagElement>
@Prop(TypeString(), tags.string.ColorLabel)
color!: number
}
@Model(tags.class.TagCategory, core.class.Doc, DOMAIN_TAGS)
@UX(tags.string.TargetCategoryLabel)
export class TTagCategory extends TDoc implements TagCategory {
@Prop(TypeString(), tags.string.AssetLabel)
icon!: Asset
@Prop(TypeString(), tags.string.CategoryLabel)
label!: IntlString
@Prop(TypeString(), tags.string.CategoryTargetClass)
targetClass!: Ref<Class<Doc>>
@Prop(Collection(core.class.TypeString), tags.string.CategoryTagsLabel)
tags!: string[]
}
export function createModel (builder: Builder): void {
builder.createModel(TTagElement, TTagReference)
builder.createModel(TTagElement, TTagReference, TTagCategory)
builder.createDoc(
core.class.Space,
+50
View File
@@ -0,0 +1,50 @@
import core, { DocumentQuery, Ref, TxOperations } from '@anticrm/core'
import { createOrUpdate, MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model'
import { getCategories } from '@anticrm/skillset'
import { findTagCategory, TagCategory, TagElement } from '@anticrm/tags'
import tags from './plugin'
export const tagsOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)
await createOrUpdate(
tx,
tags.class.TagCategory,
tags.space.Tags,
{
icon: tags.icon.Tags,
label: 'Other',
targetClass: core.class.Doc,
tags: ['']
},
tags.category.Other
)
for (const c of getCategories()) {
await createOrUpdate(
tx,
tags.class.TagCategory,
tags.space.Tags,
{
icon: tags.icon.Tags,
label: c.label,
targetClass: core.class.Doc,
tags: c.skills
},
(tags.category.Category + c.id) as Ref<TagCategory>
)
}
const categories = await tx.findAll(tags.class.TagCategory, {})
// Find all existing TagElement and update category based on skillset
const tagElements = await tx.findAll(tags.class.TagElement, { category: null } as unknown as DocumentQuery<TagElement>)
for (const t of tagElements) {
if (t.category == null) {
const category = findTagCategory(t.title, categories)
await tx.update(t, { category: category })
}
}
}
}
+10 -4
View File
@@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Doc, TxOperations } from '@anticrm/core'
import { Doc, Ref, TxOperations } from '@anticrm/core'
import { IntlString, mergeIds, Resource } from '@anticrm/platform'
import { tagsId } from '@anticrm/tags'
import { TagCategory, tagsId } from '@anticrm/tags'
import tags from '@anticrm/tags-resources/src/plugin'
import type { AnyComponent } from '@anticrm/ui'
@@ -31,14 +31,20 @@ export default mergeIds(tagsId, tags, {
TitleLabel: '' as IntlString,
DescriptionLabel: '' as IntlString,
ColorLabel: '' as IntlString,
CategoryLabel: '' as IntlString,
WeightLabel: '' as IntlString,
TagReferenceLabel: '' as IntlString,
TagLabel: '' as IntlString,
TargetClassLabel: '' as IntlString,
TagReference: '' as IntlString
TargetCategoryLabel: '' as IntlString,
TagReference: '' as IntlString,
AssetLabel: '' as IntlString,
CategoryTargetClass: '' as IntlString,
CategoryTagsLabel: '' as IntlString
},
dd: {
DeleteTagElement: '' as Resource<(doc: Doc, client: TxOperations) => Promise<Doc[]>>
},
category: {
Category: '' as Ref<TagCategory>
}
})
+3 -2
View File
@@ -34,6 +34,9 @@ import type {
} from '@anticrm/view'
import view from './plugin'
export { ObjectDDParticipant } from '@anticrm/view'
export { viewOperation } from './migration'
@Mixin(view.mixin.AttributeEditor, core.class.Class)
export class TAttributeEditor extends TClass implements AttributeEditor {
editor!: AnyComponent
@@ -190,5 +193,3 @@ export function createModel (builder: Builder): void {
}
export default view
export { viewOperation } from './migration'
+2 -1
View File
@@ -29,6 +29,7 @@
"dependencies": {
"@anticrm/core": "~0.6.11",
"@anticrm/platform": "~0.6.5",
"toposort": "^2.0.2"
"toposort": "^2.0.2",
"fast-equals": "^2.0.3"
}
}
+1
View File
@@ -15,3 +15,4 @@
export * from './dsl'
export * from './migration'
export * from './utils'
+44
View File
@@ -0,0 +1,44 @@
import { Class, Data, Doc, DocumentUpdate, Ref, Space, TxOperations } from '@anticrm/core'
import { deepEqual } from 'fast-equals'
function toUndef (value: any): any {
return value === null ? undefined : value
}
function diffAttributes (doc: Data<Doc>, newDoc: Data<Doc>): DocumentUpdate<Doc> {
const result: DocumentUpdate<any> = {}
const allDocuments = new Map(Object.entries(doc))
const newDocuments = new Map(Object.entries(newDoc))
for (const [key, value] of allDocuments) {
const newValue = toUndef(newDocuments.get(key))
if (!deepEqual(newValue, toUndef(value))) {
// update is required, since values are different
result[key] = newValue
}
}
for (const [key, value] of newDocuments) {
const oldValue = toUndef(allDocuments.get(key))
if (oldValue === undefined && value !== undefined) {
// Update with new value.
result[key] = value
}
}
return result
}
/**
* @public
*/
export async function createOrUpdate<T extends Doc> (client: TxOperations, _class: Ref<Class<T>>, space: Ref<Space>, data: Data<T>, _id: Ref<T>): Promise<void> {
const existingDoc = await client.findOne<Doc>(_class, { _id })
if (existingDoc !== undefined) {
const { _class: _oldClass, _id, space: _oldSpace, modifiedBy, modifiedOn, ...oldData } = existingDoc
const updateOp = diffAttributes(oldData, data)
if (Object.keys(updateOp).length > 0) {
await client.update(existingDoc, updateOp)
}
} else {
await client.createDoc<T>(_class, space, data, _id)
}
}
@@ -29,7 +29,7 @@
<div class="overflow-label fs-title mb-4"><Label {label} /></div>
<div class="message"><Label label={message} /></div>
<div class="footer">
<Button label={presentation.string.Ok} size={'small'} transparent primary on:click={() => dispatch('close', true)} />
<Button focus label={presentation.string.Ok} size={'small'} transparent primary on:click={() => dispatch('close', true)} />
<Button label={presentation.string.Cancel} size={'small'} transparent on:click={() => dispatch('close', false)} />
</div>
</div>
+11 -1
View File
@@ -18,6 +18,7 @@
import Spinner from './Spinner.svelte'
import Label from './Label.svelte'
import Icon from './Icon.svelte'
import { onMount } from 'svelte'
export let label: IntlString
export let primary: boolean = false
@@ -27,9 +28,18 @@
export let loading: boolean = false
export let transparent: boolean = false
export let width: string | undefined = undefined
export let focus: boolean = false
let input: HTMLButtonElement
onMount(() => {
if (focus) {
input.focus()
focus = false
}
})
</script>
<button class="button {size}" class:transparent class:primary disabled={disabled || loading} style={width ? 'width: ' + width : ''} on:click>
<button bind:this={input} class="button {size}" class:transparent class:primary disabled={disabled || loading} style={width ? 'width: ' + width : ''} on:click>
{#if icon && !loading}
<div class="icon">
<Icon {icon} size={'small'}/>
@@ -23,6 +23,7 @@
import type { DropdownTextItem } from '../types'
import { showPopup } from '..'
import { createEventDispatcher } from 'svelte'
export let title: IntlString
export let caption: IntlString | undefined = undefined
@@ -42,6 +43,8 @@
}
const none = 'None' as IntlString
const dispatch = createEventDispatcher()
</script>
<div class="flex-col cursor-pointer"
@@ -50,7 +53,10 @@
if (!opened) {
opened = true
showPopup(DropdownLabelsPopup, { title, caption, items, selected, header }, btn, (result) => {
if (result) selected = result
if (result) {
selected = result
dispatch('selected', result)
}
opened = false
})
}
+1 -1
View File
@@ -74,5 +74,5 @@ export interface ListItem {
export interface DropdownTextItem {
id: string
label: IntlString | string
label: string
}
@@ -16,9 +16,10 @@
<script lang="ts">
import contact from '@anticrm/contact'
import { Doc, DocumentQuery } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Button, Icon, Label, ScrollBox, SearchEdit, showPopup } from '@anticrm/ui'
import { Doc, DocumentQuery, Ref } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import tags, { selectedTagElements, TagCategory, TagElement } from '@anticrm/tags'
import { Button, Component, Icon, Label, ScrollBox, SearchEdit, showPopup } from '@anticrm/ui'
import view, { Viewlet } from '@anticrm/view'
import { Table } from '@anticrm/view-resources'
import recruit from '../plugin'
@@ -26,18 +27,38 @@
let search = ''
let resultQuery: DocumentQuery<Doc> = {}
function updateResultQuery (search: string): void {
resultQuery = (search === '') ? { } : { $search: search }
}
const client = getClient()
const tableDescriptor = client.findOne<Viewlet>(view.class.Viewlet, { attachTo: recruit.mixin.Candidate, descriptor: view.viewlet.Table })
function showCreateDialog (ev: Event) {
showPopup(CreateCandidate, { space: recruit.space.CandidatesPublic }, ev.target as HTMLElement)
}
let category: Ref<TagCategory> | undefined = undefined
let documentIds:Ref<Doc>[] = []
async function updateResultQuery (search: string, documentIds:Ref<Doc>[]): Promise<void> {
resultQuery = (search === '') ? { } : { $search: search }
if (documentIds.length > 0) {
resultQuery._id = { $in: documentIds }
}
}
// Find all tags for object classe with matched elements
const query = createQuery()
$: query.query(tags.class.TagReference, { tag: { $in: $selectedTagElements } }, (result) => {
documentIds = Array.from(new Set<Ref<Doc>>(result.map(it => it.attachedTo)).values())
})
$: updateResultQuery(search, documentIds)
function updateCategory (detail: {category: Ref<TagCategory> | null, elements: TagElement[] }) {
console.log(detail)
category = detail.category ?? undefined
selectedTagElements.set(Array.from(detail.elements ?? []).map(it => it._id))
}
</script>
<div class="ac-header full">
@@ -47,11 +68,13 @@
</div>
<SearchEdit bind:value={search} on:change={() => {
updateResultQuery(search)
updateResultQuery(search, documentIds)
}} />
<Button label={recruit.string.Create} primary={true} size={'small'} on:click={(ev) => showCreateDialog(ev)} />
</div>
<Component is={tags.component.TagsCategoryBar} props={{ targetClass: recruit.mixin.Candidate, category }} on:change={(evt) => updateCategory(evt.detail) }/>
<ScrollBox vertical stretch noShift>
{#await tableDescriptor then descr}
{#if descr}
@@ -30,7 +30,7 @@
} from '@anticrm/presentation'
import type { Candidate } from '@anticrm/recruit'
import { recognizeDocument } from '@anticrm/rekoni'
import tags, { TagElement, TagReference } from '@anticrm/tags'
import tags, { findTagCategory, findTagCategory, TagElement, TagReference } from '@anticrm/tags'
import {
Component,
EditBox,
@@ -153,6 +153,8 @@
}
)
}
const categories = await client.findAll(tags.class.TagCategory, {})
// Tag elements
const skillTagElements = new Map(Array.from((await client.findAll(tags.class.TagElement, { _id: { $in: skills.map(it => it.tag) } })).map(it => ([it._id, it]))))
for (const skill of skills) {
@@ -162,7 +164,8 @@
title: skill.title,
color: skill.color,
targetClass: recruit.mixin.Candidate,
description: ''
description: '',
category: findTagCategory(skill.title, categories)
})
}
await client.addCollection(skill._class, skill.space, candidateId, recruit.mixin.Candidate, 'skills', {
@@ -243,6 +246,9 @@
// Create skills
await elementsPromise
const categories = await client.findAll(tags.class.TagCategory, {})
const categoriesMap = new Map(Array.from(categories.map(it => ([it._id, it]))))
const newSkills:TagReference[] = []
// Create missing tag elemnts
for (const s of doc.skills ?? []) {
@@ -250,12 +256,15 @@
let e = namedElements.get(title)
if (e === undefined) {
// No yet tag with title
const category = findTagCategory(s, categories)
const cinstance = categoriesMap.get(category)
e = TxProcessor.createDoc2Doc(
client.txFactory.createTxCreateDoc(tags.class.TagElement, tags.space.Tags, {
title,
description: '',
description: `Imported skill ${s} of ${cinstance?.label ?? ''}`,
color: getColorNumberByText(s),
targetClass: recruit.mixin.Candidate
targetClass: recruit.mixin.Candidate,
category
})
)
namedElements.set(title, e)
+8 -1
View File
@@ -24,6 +24,13 @@
"SaveLabel": "Save",
"WeightLabel": "Weight",
"WeightPlaceholder": "Please type weight here",
"CategoryPlaceholder": "Please type category here"
"CategoryPlaceholder": "Please type category here",
"AssetLabel": "Asset",
"CategoryTargetClass": "Category",
"CategoryTagsLabel": "Category Items",
"DataStructuresLabel": "Data Structures",
"OtherCategoryLabel": "Other",
"AllCategories": "All Categories"
}
}
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Слой_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 36 36" style="enable-background:new 0 0 36 36;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_2_);}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="14.3791" y1="-2.5354" x2="21.6209" y2="38.5354">
<stop offset="5.076140e-03" style="stop-color:#C52229"/>
<stop offset="1" style="stop-color:#802022"/>
</linearGradient>
<rect class="st0" width="36" height="36"/>
<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="18" y1="6.2743" x2="18" y2="29.7257">
<stop offset="0" style="stop-color:#FFFFFF"/>
<stop offset="0.5066" style="stop-color:#FDFDFD"/>
<stop offset="0.7696" style="stop-color:#F5F5F5"/>
<stop offset="0.9763" style="stop-color:#E8E8E8"/>
<stop offset="1" style="stop-color:#E6E6E6"/>
</linearGradient>
<path class="st1" d="M7.3,9.8c2.8-1.1,7.4-1.3,9.4-1.2l1.3,1.6l1.3-1.6c2-0.1,6.6,0.1,9.4,1.2c-0.4,0.7-2.1,1.8-3,2
c-0.1-1.4-2.1-1.7-4-1.7L18,29.7l-3.7-19.7c-2,0-4,0.3-4,1.7C9.4,11.6,7.7,10.5,7.3,9.8L7.3,9.8z M6,7.8l0.7,1.1
c2.7-1,5.9-1.4,9.3-1.5c1.3,0,2.6,0,3.9,0c3.4,0.1,6.6,0.6,9.3,1.5L30,7.8c-3.2-1.1-6.6-1.4-10-1.5c-1.3,0-2.6,0-4,0
C12.6,6.4,9.2,6.7,6,7.8L6,7.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,164 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, Ref, SortingOrder } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import { TagCategory, TagElement } from '@anticrm/tags'
import tags from '../plugin'
import { getPlatformColorForText, Label } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import { getTagStyle } from '../utils'
export let targetClass: Ref<Class<Doc>>
export let category: Ref<TagCategory> | undefined = undefined
export let gap: 'small' | 'big' = 'small'
let categories: TagCategory[] = []
let visibleCategories: TagCategory[] = []
const stepStyle = gap === 'small' ? 'gap-1' : 'gap-2'
const dispatch = createEventDispatcher()
const client = getClient()
let elements: TagElement[] = []
let categoryCounts = new Map<Ref<TagCategory>, TagElement[]>()
let categoryKeys: Ref<TagCategory>[] = []
const elementsQuery = createQuery()
$: elementsQuery.query(
tags.class.TagElement,
{ targetClass: { $in: [targetClass, ...client.getHierarchy().getAncestors(targetClass)] } },
(res) => {
elements = res
},
{
sort: {
title: SortingOrder.Ascending
}
}
)
$: {
const counts = new Map<Ref<TagCategory>, TagElement[]>()
for (const e of elements) {
if (e.category !== undefined) {
const els = counts.get(e.category) ?? []
els.push(e)
counts.set(e.category, els)
}
}
categoryCounts = counts
categoryKeys = Array.from(categoryCounts.keys())
}
const categoriesQuery = createQuery()
$: categoriesQuery.query(
tags.class.TagCategory,
{},
(res) => {
categories = res
},
{ sort: { label: SortingOrder.Ascending } }
)
$: visibleCategories = categories.filter((it) => categoryKeys.includes(it._id))
const selectItem = (ev: Event, item: TagCategory): void => {
if (category === item._id) {
category = undefined
} else {
category = item._id
}
dispatch('change', { category, elements: category !== undefined ? categoryCounts.get(category) ?? [] : [] })
}
</script>
{#if visibleCategories.length > 0}
<div class="flex-between mb-4 header">
<div class="flex-row-center buttons">
<div
class="button flex-center"
class:active={category === undefined}
on:click={() => {
category = undefined
dispatch('change', { category, elements: [] })
}}
>
<Label label={tags.string.AllCategories} />
</div>
</div>
<div class="flex-row-center caption-color states">
<div class="antiStatesBar mask-none {stepStyle}">
{#each visibleCategories as item, i (item._id)}
<div
class="categoryElement flex-center"
label={item.label}
style={getTagStyle(getPlatformColorForText(item.label), item._id === category)}
on:click={(ev) => {
if (item._id !== category) selectItem(ev, item)
}}
>
{item.label} ({categoryCounts.get(item._id)?.length ?? ''})
</div>
{/each}
</div>
</div>
</div>
{/if}
<style lang="scss">
.categoryElement {
padding: 0.5rem 0.75rem;
height: 2.5rem;
white-space: nowrap;
border: 1px solid var(--theme-button-border-enabled);
border-radius: 0.5rem;
cursor: pointer;
}
.categoryElement + .categoryElement {
margin-left: 0.75rem;
}
.header {
margin-left: 2.5rem;
margin-right: 1.75rem;
.buttons {
padding: 0.125rem 0;
}
.states {
max-width: 75%;
}
.button {
height: 2.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid transparent;
border-radius: 0.5rem;
cursor: pointer;
&:hover {
background-color: var(--theme-button-bg-enabled);
border-color: var(--theme-button-border-enabled);
}
&.active {
background-color: var(--theme-button-bg-enabled);
color: var(--theme-caption-color);
border-color: var(--theme-button-border-enabled);
}
}
}
</style>
@@ -0,0 +1,23 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { TagCategory } from '@anticrm/tags'
export let value: TagCategory
</script>
{#if value}
{value.label}
{/if}
@@ -14,9 +14,10 @@
-->
<script lang="ts">
import { Class, Data, Doc, generateId, Ref } from '@anticrm/core'
import { Card, getClient } from '@anticrm/presentation'
import { TagElement } from '@anticrm/tags'
import { EditBox, getColorNumberByText, getPlatformColor, showPopup } from '@anticrm/ui'
import { Card, createQuery, getClient } from '@anticrm/presentation'
import { findTagCategory, TagCategory, TagElement } from '@anticrm/tags'
import { DropdownLabels, EditBox, getColorNumberByText, getPlatformColor, showPopup } from '@anticrm/ui'
import { DropdownTextItem } from '@anticrm/ui/src/types'
import { ColorsPopup } from '@anticrm/view-resources'
import { createEventDispatcher } from 'svelte'
import tags from '../plugin'
@@ -29,12 +30,22 @@
let description = ''
let color: number = 0
let categoryWasSet = false
let category: Ref<TagCategory> = tags.category.Other
let categories: TagCategory[] = []
let categoryItems: DropdownTextItem[] = []
let colorSet = false
$: if (!colorSet) {
color = getColorNumberByText(title)
}
$: if (!categoryWasSet) {
category = findTagCategory(title, categories)
}
export function canClose (): boolean {
return title === ''
}
@@ -43,12 +54,27 @@
const client = getClient()
const tagElementId = generateId()
const query = createQuery()
query.query(tags.class.TagCategory, {}, async (result) => {
const newItems: DropdownTextItem[] = []
for (const r of result) {
newItems.push({
id: r._id,
label: r.label
})
}
categories = result
categoryItems = newItems
})
async function createTagElenent () {
const tagElement: Data<TagElement> = {
title: title,
description,
targetClass,
color
color,
category: category
}
await client.createDoc(tags.class.TagElement, tags.space.Tags, tagElement, tagElementId)
@@ -93,6 +119,17 @@
<div class="text-sm mt-4">
<EditBox placeholder={tags.string.TagDescriptionPlaceholder} maxWidth="15rem" bind:value={description} />
</div>
<div class="text-sm mt-4">
<DropdownLabels
title={tags.string.CategoryLabel}
bind:selected={category}
items={categoryItems}
on:selected={() => {
categoryWasSet = true
}}
/>
</div>
</div>
</div>
</Card>
@@ -14,9 +14,10 @@
-->
<script lang="ts">
import core, { Data, DocumentUpdate } from '@anticrm/core'
import { Card, getClient } from '@anticrm/presentation'
import { Card, createQuery, getClient } from '@anticrm/presentation'
import { TagElement, TagReference } from '@anticrm/tags'
import { EditBox, getPlatformColor, showPopup } from '@anticrm/ui'
import { DropdownLabels, EditBox, getPlatformColor, showPopup } from '@anticrm/ui'
import { DropdownTextItem } from '@anticrm/ui/src/types'
import { ColorsPopup } from '@anticrm/view-resources'
import { createEventDispatcher } from 'svelte'
import tags from '../plugin'
@@ -28,10 +29,13 @@
const dispatch = createEventDispatcher()
const client = getClient()
let categoryItems: DropdownTextItem[] = []
const data: Omit<Data<TagElement>, 'targetClass'> = {
title: value.title,
description: value.description,
color: value.color
color: value.color,
category: value.category
}
async function updateElement () {
@@ -47,6 +51,10 @@
documentUpdate.description = data.description
}
if (data.category !== value.category) {
documentUpdate.category = data.category
}
if (data.color !== value.color) {
documentUpdate.color = data.color
refUpdate.color = data.color
@@ -67,6 +75,18 @@
dispatch('close')
}
const query = createQuery()
query.query(tags.class.TagCategory, {}, async (result) => {
const newItems: DropdownTextItem[] = []
for (const r of result) {
newItems.push({
id: r._id,
label: r.label
})
}
categoryItems = newItems
})
</script>
<Card
@@ -105,6 +125,10 @@
<div class="fs-title mt-4">
<EditBox placeholder={tags.string.TagDescriptionPlaceholder} maxWidth="15rem" bind:value={data.description} />
</div>
<div class="text-sm mt-4">
<DropdownLabels title={tags.string.CategoryLabel} bind:selected={data.category} items={categoryItems} />
</div>
</div>
</div>
</Card>
@@ -23,6 +23,7 @@
export let tag: TagReference | undefined = undefined
export let element: TagElement | undefined = undefined
export let action: Asset | AnySvelteComponent | undefined = undefined
export let selected: boolean = false
const dispatch = createEventDispatcher()
@@ -36,7 +37,7 @@
>
<div
class="text-sm flex flex-between tag-item"
style={`${getTagStyle(getPlatformColor(tag?.color ?? element?.color ?? 0))}`}
style={`${getTagStyle(getPlatformColor(tag?.color ?? element?.color ?? 0), selected)}`}
>
{name}
{#if action}
@@ -21,8 +21,6 @@
$: values = Array.isArray(value) ? value : [value]
</script>
{#each values as v, i}
{#if i < 2}
<TagItem tag={v} />
{/if}
{#each values as v}
<TagItem tag={v} />
{/each}
@@ -18,6 +18,7 @@
import { TagElement, TagReference } from '@anticrm/tags'
import tags from '../plugin'
import TagItem from './TagItem.svelte'
import { selectedTagElements } from '@anticrm/tags'
export let object: Doc
export let _class: Ref<Class<Doc>>
@@ -43,8 +44,8 @@
</script>
<div class="tags flex flex-wrap">
{#each items as tag, i}
<TagItem {tag} element={elements.get(tag.tag)} />
{#each items as tag}
<TagItem {tag} element={elements.get(tag.tag)} selected={$selectedTagElements.includes(tag.tag)} />
{/each}
</div>
@@ -13,11 +13,13 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, Ref } from '@anticrm/core'
import { Class, Doc, DocumentQuery, FindOptions, Ref } from '@anticrm/core'
import { IntlString, translate } from '@anticrm/platform'
import { TagCategory, TagElement } from '@anticrm/tags'
import { Button, Icon, Label, ScrollBox, SearchEdit, showPopup } from '@anticrm/ui'
import { Table } from '@anticrm/view-resources'
import tags from '../plugin'
import CategoryBar from './CategoryBar.svelte'
import CreateTagElement from './CreateTagElement.svelte'
export let title: IntlString = tags.string.Tags
@@ -30,15 +32,24 @@
})
let search = ''
let resultQuery: DocumentQuery<Doc> = { targetClass }
let resultQuery: DocumentQuery<TagElement> = { targetClass }
function updateResultQuery (search: string): void {
function updateResultQuery (search: string, category?: Ref<TagCategory>): void {
resultQuery = search === '' ? { targetClass } : { $search: search, targetClass }
if (category !== undefined) {
resultQuery.category = category
}
}
function showCreateDialog (ev: Event) {
showPopup(CreateTagElement, { targetClass, keyTitle }, ev.target as HTMLElement)
}
const opt: FindOptions<TagElement> = {
lookup: {
category: tags.class.TagCategory
}
}
let category: Ref<TagCategory> | undefined = undefined
</script>
<div class="ac-header full">
@@ -50,12 +61,20 @@
<SearchEdit
bind:value={search}
on:change={() => {
updateResultQuery(search)
updateResultQuery(search, category)
}}
/>
<Button label={tags.string.CreateItemLabel} primary={true} size={'small'} on:click={(ev) => showCreateDialog(ev)} />
</div>
<CategoryBar
{targetClass}
{category}
on:change={(evt) => {
category = evt.detail.category ?? undefined
updateResultQuery(search, category)
}}
/>
<ScrollBox vertical stretch noShift>
<Table
_class={tags.class.TagElement}
@@ -67,10 +86,20 @@
props: { edit: true, keyTitle },
sortingKey: 'title'
},
...(category === undefined
? [
{
key: '$lookup.category',
presenter: tags.component.CategoryPresenter,
sortingKey: '$lookup.category',
label: tags.string.CategoryLabel
}
]
: []),
'description',
'modifiedOn'
]}
options={{}}
options={opt}
query={resultQuery}
enableChecking
/>
+5 -1
View File
@@ -21,7 +21,9 @@ import TagsPresenter from './components/TagsPresenter.svelte'
import TagsItemPresenter from './components/TagsItemPresenter.svelte'
import TagsView from './components/TagsView.svelte'
import TagsEditor from './components/TagsEditor.svelte'
import CategoryPresenter from './components/CategoryPresenter.svelte'
import tags from './plugin'
import TagsCategoryBar from './components/CategoryBar.svelte'
async function DeleteTagElement (doc: TagElement, client: TxOperations): Promise<Doc[]> {
return await client.findAll(tags.class.TagReference, { tag: doc._id })
@@ -35,7 +37,9 @@ export default async (): Promise<Resources> => ({
TagsPresenter,
TagsView,
TagsEditor,
TagsItemPresenter
TagsItemPresenter,
CategoryPresenter,
TagsCategoryBar
},
dd: {
DeleteTagElement
+5 -2
View File
@@ -17,7 +17,8 @@ import { AnyComponent } from '@anticrm/ui'
export default mergeIds(tagsId, tags, {
component: {
TagElementPresenter: '' as AnyComponent
TagElementPresenter: '' as AnyComponent,
CategoryPresenter: '' as AnyComponent
},
string: {
NoTags: '' as IntlString,
@@ -38,6 +39,8 @@ export default mergeIds(tagsId, tags, {
Tags: '' as IntlString,
Tag: '' as IntlString,
TagName: '' as IntlString,
SaveLabel: '' as IntlString
SaveLabel: '' as IntlString,
CategoryLabel: '' as IntlString,
AllCategories: '' as IntlString
}
})
+3 -14
View File
@@ -1,19 +1,8 @@
// 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.
export function getTagStyle (color: string): string {
export function getTagStyle (color: string, selected = false): string {
return `
background: ${color + '33'};
border: 1px solid ${color + '66'};
background: ${color + (selected ? 'ff' : '33')};
border: 1px solid ${color + (selected ? 'ff' : '66')};
`
}
+2 -1
View File
@@ -28,6 +28,7 @@
"dependencies": {
"@anticrm/platform": "~0.6.5",
"@anticrm/core": "~0.6.11",
"@anticrm/ui": "~0.6.0"
"@anticrm/ui": "~0.6.0",
"svelte": "^3.37.0"
}
}
+49 -3
View File
@@ -17,6 +17,7 @@ import type { AttachedDoc, Class, Doc, Ref, Space } from '@anticrm/core'
import type { Asset, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
import { AnyComponent } from '@anticrm/ui'
import { writable } from 'svelte/store'
/**
* @public
@@ -26,6 +27,7 @@ export interface TagElement extends Doc {
targetClass: Ref<Class<Doc>>
description: string
color: number
category: Ref<TagCategory>
}
/**
@@ -37,15 +39,33 @@ export interface TagReference extends AttachedDoc {
color: number // Copy of color from tagElement. Updated with trigger.
}
/**
* Defined set of skills per category.
*
* Will be used as skill category templates or category detection
* @public
*/
export interface TagCategory extends Doc {
icon: Asset
label: string
targetClass: Ref<Class<Doc>>
// A list of possible variants.
tags: string[]
}
/**
* @public
*/
export const tagsId = 'tags' as Plugin
export default plugin(tagsId, {
/**
* @public
*/
const tagsPlugin = plugin(tagsId, {
class: {
TagElement: '' as Ref<Class<TagElement>>,
TagReference: '' as Ref<Class<TagReference>>
TagReference: '' as Ref<Class<TagReference>>,
TagCategory: '' as Ref<Class<TagCategory>>
},
space: {
Tags: '' as Ref<Space>
@@ -55,6 +75,32 @@ export default plugin(tagsId, {
},
component: {
TagsView: '' as AnyComponent,
TagsEditor: '' as AnyComponent
TagsEditor: '' as AnyComponent,
TagsCategoryBar: '' as AnyComponent
},
category: {
Other: '' as Ref<TagCategory>
}
})
/**
* @public
*/
export default tagsPlugin
/**
* @public
*/
export function findTagCategory (title: string, categories: TagCategory[]): Ref<TagCategory> {
for (const c of categories) {
if (c.tags.findIndex((it) => it.toLowerCase() === title.toLowerCase()) !== -1) {
return c._id
}
}
return tagsPlugin.category.Other
}
/**
* @public
*/
export const selectedTagElements = writable<Ref<TagElement>[]>([])
@@ -33,7 +33,7 @@
let maskLeft: boolean = false
let maskRight: boolean = false
let mask: 'left' | 'right' | 'both' | 'none' = 'none'
let stepStyle = (gap === 'small') ? 'gap-1' : 'gap-2'
$: stepStyle = (gap === 'small') ? 'gap-1' : 'gap-2'
const dispatch = createEventDispatcher()