TSK-608: Move Vacancy support. (#2597)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-02-07 18:15:59 +07:00 committed by GitHub
parent 472cb40dda
commit 243bc1dede
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 422 additions and 75 deletions

View File

@ -1014,6 +1014,24 @@ export function createModel (builder: Builder): void {
label: recruit.string.RelatedIssues
}
})
createAction(
builder,
{
label: view.string.Move,
action: recruit.actionImpl.MoveApplicant,
icon: view.icon.Move,
input: 'any',
category: view.category.General,
target: recruit.class.Applicant,
context: {
mode: ['context', 'browser'],
group: 'tools'
},
override: [task.action.Move]
},
recruit.action.MoveApplicant
)
}
export { recruitOperation } from './migration'

View File

@ -29,10 +29,12 @@ export default mergeIds(recruitId, recruit, {
CreateGlobalApplication: '' as Ref<Action>,
CopyApplicationId: '' as Ref<Action>,
CopyApplicationLink: '' as Ref<Action>,
CopyCandidateLink: '' as Ref<Action>
CopyCandidateLink: '' as Ref<Action>,
MoveApplicant: '' as Ref<Action>
},
actionImpl: {
CreateOpinion: '' as ViewAction
CreateOpinion: '' as ViewAction,
MoveApplicant: '' as ViewAction
},
category: {
Recruit: '' as Ref<ActionCategory>

View File

@ -230,30 +230,34 @@ export class LiveQuery extends TxProcessor implements Client {
}
}
private async checkSearch (q: Query, pos: number, _id: Ref<Doc>): Promise<boolean> {
private async checkSearch (q: Query, _id: Ref<Doc>): Promise<boolean> {
const match = await this.findOne(q._class, { $search: q.query.$search, _id }, q.options)
if (q.result instanceof Promise) {
q.result = await q.result
}
const match = await this.findOne(q._class, { $search: q.query.$search, _id }, q.options)
if (match === undefined) {
if (q.options?.limit === q.result.length) {
await this.refresh(q)
return true
} else {
const pos = q.result.findIndex((p) => p._id === _id)
q.result.splice(pos, 1)
q.total--
}
} else {
const pos = q.result.findIndex((p) => p._id === _id)
q.result[pos] = match
}
return false
}
private async getCurrentDoc (q: Query, pos: number, _id: Ref<Doc>): Promise<boolean> {
private async getCurrentDoc (q: Query, _id: Ref<Doc>): Promise<boolean> {
const current = await this.findOne(q._class, { _id }, q.options)
if (q.result instanceof Promise) {
q.result = await q.result
}
const current = await this.findOne(q._class, { _id }, q.options)
const pos = q.result.findIndex((p) => p._id === _id)
if (current !== undefined && this.match(q, current)) {
q.result[pos] = current
} else {
@ -279,10 +283,11 @@ export class LiveQuery extends TxProcessor implements Client {
await this.__updateLookup(q, updatedDoc, ops)
}
private async checkUpdatedDocMatch (q: Query, pos: number, updatedDoc: WithLookup<Doc>): Promise<boolean> {
private async checkUpdatedDocMatch (q: Query, updatedDoc: WithLookup<Doc>): Promise<boolean> {
if (q.result instanceof Promise) {
q.result = await q.result
}
const pos = q.result.findIndex((p) => p._id === updatedDoc._id)
if (!this.match(q, updatedDoc)) {
if (q.options?.limit === q.result.length) {
await this.refresh(q)
@ -315,21 +320,22 @@ export class LiveQuery extends TxProcessor implements Client {
if (pos !== -1) {
// If query contains search we must check use fulltext
if (q.query.$search != null && q.query.$search.length > 0) {
const searchRefresh = await this.checkSearch(q, pos, tx.objectId)
const searchRefresh = await this.checkSearch(q, tx.objectId)
if (searchRefresh) return {}
} else {
const updatedDoc = q.result[pos]
if (updatedDoc.modifiedOn < tx.modifiedOn) {
await this.__updateMixinDoc(q, updatedDoc, tx)
const updateRefresh = await this.checkUpdatedDocMatch(q, pos, updatedDoc)
const updateRefresh = await this.checkUpdatedDocMatch(q, updatedDoc)
if (updateRefresh) return {}
} else {
const currentRefresh = await this.getCurrentDoc(q, pos, updatedDoc._id)
const currentRefresh = await this.getCurrentDoc(q, updatedDoc._id)
if (currentRefresh) return {}
}
}
this.sort(q, tx)
await this.updatedDocCallback(q.result[pos], q)
const udoc = q.result.find((p) => p._id === tx.objectId)
await this.updatedDocCallback(udoc, q)
} else if (isMixin) {
// Mixin potentially added to object we doesn't have in out results
const doc = await this.findOne(q._class, { _id: tx.objectId }, q.options)
@ -398,24 +404,26 @@ export class LiveQuery extends TxProcessor implements Client {
if (pos !== -1) {
// If query contains search we must check use fulltext
if (q.query.$search != null && q.query.$search.length > 0) {
const searchRefresh = await this.checkSearch(q, pos, tx.objectId)
const searchRefresh = await this.checkSearch(q, tx.objectId)
if (searchRefresh) return
} else {
const updatedDoc = q.result[pos]
if (updatedDoc.modifiedOn < tx.modifiedOn) {
await this.__updateDoc(q, updatedDoc, tx)
const updateRefresh = await this.checkUpdatedDocMatch(q, pos, updatedDoc)
const updateRefresh = await this.checkUpdatedDocMatch(q, updatedDoc)
if (updateRefresh) return
} else {
const currentRefresh = await this.getCurrentDoc(q, pos, updatedDoc._id)
const currentRefresh = await this.getCurrentDoc(q, updatedDoc._id)
if (currentRefresh) return
}
}
this.sort(q, tx)
await this.updatedDocCallback(q.result[pos], q)
const udoc = q.result.find((p) => p._id === tx.objectId)
await this.updatedDocCallback(udoc, q)
} else if (await this.matchQuery(q, tx)) {
this.sort(q, tx)
await this.updatedDocCallback(q.result[pos], q)
const udoc = q.result.find((p) => p._id === tx.objectId)
await this.updatedDocCallback(udoc, q)
}
await this.handleDocUpdateLookup(q, tx)
}
@ -953,10 +961,13 @@ export class LiveQuery extends TxProcessor implements Client {
return false
}
private async updatedDocCallback (updatedDoc: Doc, q: Query): Promise<void> {
private async updatedDocCallback (updatedDoc: Doc | undefined, q: Query): Promise<void> {
q.result = q.result as Doc[]
if (q.options?.limit !== undefined && q.result.length > q.options.limit) {
if (updatedDoc === undefined) {
return await this.refresh(q)
}
if (q.result[q.options?.limit]._id === updatedDoc._id) {
return await this.refresh(q)
}

View File

@ -14,12 +14,11 @@
// limitations under the License.
-->
<script lang="ts">
import { onMount } from 'svelte'
import { fitPopupElement } from '../popups'
import type { AnyComponent, AnySvelteComponent, PopupAlignment, PopupOptions, PopupPositionElement } from '../types'
import { deviceOptionsStore as deviceInfo } from '..'
import { fitPopupElement } from '../popups'
import type { AnySvelteComponent, PopupAlignment, PopupOptions, PopupPositionElement } from '../types'
export let is: AnyComponent | AnySvelteComponent
export let is: AnySvelteComponent
export let props: object
export let element: PopupAlignment | undefined
export let onClose: ((result: any) => void) | undefined
@ -68,8 +67,7 @@
_close(undefined)
}
const fitPopup = (): void => {
if (modalHTML) {
const fitPopup = (modalHTML: HTMLElement, element: PopupAlignment | undefined): void => {
if ((fullSize || docSize) && element === 'float') {
options = fitPopupElement(modalHTML, 'full')
options.props.maxHeight = '100vh'
@ -80,7 +78,6 @@
}
options.fullSize = fullSize
}
}
function handleKeydown (ev: KeyboardEvent) {
if (ev.key === 'Escape' && is && top) {
@ -102,19 +99,35 @@
const alignment: PopupPositionElement = element as PopupPositionElement
let showing: boolean | undefined = alignment?.kind === 'submenu' ? undefined : false
onMount(() => {
fitPopup()
setTimeout(() => {
modalHTML.addEventListener('transitionend', () => (showing = undefined), { once: true })
let oldModalHTML: HTMLElement | undefined = undefined
$: if (modalHTML !== undefined && oldModalHTML !== modalHTML) {
oldModalHTML = modalHTML
fitPopup(modalHTML, element)
showing = true
}, 0)
})
modalHTML.addEventListener(
'transitionend',
() => {
showing = undefined
},
{ once: true }
)
}
$: if ($deviceInfo.docWidth <= 900 && !docSize) docSize = true
$: if ($deviceInfo.docWidth > 900 && docSize) docSize = false
</script>
<svelte:window on:resize={fitPopup} on:keydown={handleKeydown} />
<svelte:window
on:resize={() => {
if (modalHTML) {
fitPopup(modalHTML, element)
}
}}
on:keydown={handleKeydown}
/>
{JSON.stringify(options)}
<div
class="popup {showing === undefined ? 'endShow' : showing === false ? 'preShow' : 'startShow'}"
class:anim={element === 'float'}
@ -143,10 +156,10 @@
on:close={(ev) => _close(ev?.detail)}
on:fullsize={() => {
fullSize = !fullSize
fitPopup()
fitPopup(modalHTML, element)
}}
on:changeContent={() => {
fitPopup()
fitPopup(modalHTML, element)
}}
/>
</div>

View File

@ -55,14 +55,15 @@ export function showPopup (
return popups
})
}
const _element = element instanceof HTMLElement ? getPopupPositionElement(element) : element
if (typeof component === 'string') {
getResource(component)
.then((resolved) =>
addPopup({ id, is: resolved, props, element, onClose, onUpdate, close: closePopupOp, options })
addPopup({ id, is: resolved, props, element: _element, onClose, onUpdate, close: closePopupOp, options })
)
.catch((err) => console.log(err))
} else {
addPopup({ id, is: component, props, element, onClose, onUpdate, close: closePopupOp, options })
addPopup({ id, is: component, props, element: _element, onClose, onUpdate, close: closePopupOp, options })
}
return closePopupOp
}
@ -352,7 +353,8 @@ export function getPopupPositionElement (
return undefined
}
export function getEventPositionElement (evt: MouseEvent): PopupAlignment | undefined {
const rect = DOMRect.fromRect({ width: 1, height: 1, x: evt.clientX, y: evt.clientY })
return {
getBoundingClientRect: () => DOMRect.fromRect({ width: 1, height: 1, x: evt.clientX, y: evt.clientY })
getBoundingClientRect: () => rect
}
}

View File

@ -104,7 +104,8 @@
"VacancyMatching": "Match Talents to vacancy",
"Score": "Score",
"Match": "Match",
"PerformMatch": "Match"
"PerformMatch": "Match",
"MoveApplication": "Move to another vacancy"
},
"status": {
"TalentRequired": "Please select talent",

View File

@ -106,7 +106,8 @@
"VacancyMatching": "Подбор кандидатов на вакансию",
"Score": "Оценка",
"Match": "Совпадение",
"PerformMatch": "Сопоставить"
"PerformMatch": "Сопоставить",
"MoveApplication": "Поменять Вакансию"
},
"status": {
"TalentRequired": "Пожалуйста выберите таланта",

View File

@ -0,0 +1,7 @@
import { Doc } from '@hcengineering/core'
import { showPopup } from '@hcengineering/ui'
import MoveApplication from './components/MoveApplication.svelte'
export async function MoveApplicant (docs: Doc | Doc[]): Promise<void> {
showPopup(MoveApplication, { selected: Array.isArray(docs) ? docs : [docs] })
}

View File

@ -21,10 +21,11 @@
import { createQuery, getClient } from '@hcengineering/presentation'
import { Vacancy } from '@hcengineering/recruit'
import { FullDescriptionBox } from '@hcengineering/text-editor'
import { Button, EditBox, Grid, IconMoreH, showPopup } from '@hcengineering/ui'
import { Button, Component, EditBox, Grid, IconMoreH, showPopup } from '@hcengineering/ui'
import { ClassAttributeBar, ContextMenu } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
import tracker from '@hcengineering/tracker'
export let _id: Ref<Vacancy>
@ -132,6 +133,7 @@
space={object.space}
attachments={object.attachments ?? 0}
/>
<Component is={tracker.component.RelatedIssuesSection} props={{ object, label: recruit.string.RelatedIssues }} />
</Grid>
</Panel>
{/if}

View File

@ -0,0 +1,265 @@
<!--
// Copyright © 2023 Anticrm Platform Contributors.
//
// 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 contact from '@hcengineering/contact'
import ExpandRightDouble from '@hcengineering/contact-resources/src/components/icons/ExpandRightDouble.svelte'
import { FindOptions, SortingOrder } from '@hcengineering/core'
import { OK, Severity, Status } from '@hcengineering/platform'
import presentation, { Card, createQuery, getClient, SpaceSelect } from '@hcengineering/presentation'
import type { Applicant, Vacancy } from '@hcengineering/recruit'
import task, { State } from '@hcengineering/task'
import ui, {
Button,
ColorPopup,
createFocusManager,
deviceOptionsStore as deviceInfo,
FocusHandler,
getPlatformColor,
Label,
ListView,
showPopup,
Status as StatusControl
} from '@hcengineering/ui'
import { moveToSpace } from '@hcengineering/view-resources/src/utils'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
import ApplicationPresenter from './ApplicationPresenter.svelte'
import VacancyCard from './VacancyCard.svelte'
import VacancyOrgPresenter from './VacancyOrgPresenter.svelte'
export let selected: Applicant[]
const status: Status = OK
let _space = selected[0]?.space
const dispatch = createEventDispatcher()
const client = getClient()
export function canClose (): boolean {
return true
}
let loading = false
async function updateApplication () {
loading = true
if (selectedState === undefined) {
throw new Error(`Please select initial state:${_space}`)
}
const state = await client.findOne(task.class.State, { space: _space, _id: selectedState?._id })
if (state === undefined) {
throw new Error(`create application: state not found space:${_space}`)
}
const op = client.apply('application.states')
for (const a of selected) {
await moveToSpace(op, a, _space, { state: state._id, doneState: null })
}
await op.commit()
loading = false
dispatch('close')
}
let states: Array<{ id: number | string; color: number; label: string }> = []
let selectedState: State | undefined
let rawStates: State[] = []
const statesQuery = createQuery()
const spaceQuery = createQuery()
let vacancy: Vacancy | undefined
$: if (_space) {
statesQuery.query(
task.class.State,
{ space: _space },
(res) => {
rawStates = res
},
{ sort: { rank: SortingOrder.Ascending } }
)
spaceQuery.query(recruit.class.Vacancy, { _id: _space }, (res) => {
vacancy = res.shift()
})
}
$: if (rawStates.findIndex((it) => it._id === selectedState?._id) === -1) {
selectedState = rawStates[0]
}
$: states = rawStates.map((s) => {
return { id: s._id, label: s.title, color: s.color }
})
const manager = createFocusManager()
const orgOptions: FindOptions<Vacancy> = {
lookup: {
company: contact.class.Organization
}
}
let verticalContent: boolean = false
$: verticalContent = $deviceInfo.isMobile && $deviceInfo.isPortrait
let btn: HTMLButtonElement
</script>
<FocusHandler {manager} />
<Card
label={recruit.string.MoveApplication}
okAction={updateApplication}
okLabel={presentation.string.Save}
canSave={status.severity === Severity.OK}
on:close={() => {
dispatch('close')
}}
>
<svelte:fragment slot="title">
<div class="flex-row-center gap-2">
<Label label={recruit.string.MoveApplication} />
</div>
</svelte:fragment>
<StatusControl slot="error" {status} />
<div class:candidate-vacancy={!verticalContent} class:flex-col={verticalContent}>
<div class="flex flex-stretch vacancyList">
<ListView count={selected.length}>
<svelte:fragment slot="item" let:item>
<ApplicationPresenter value={selected[item]} />
</svelte:fragment>
</ListView>
</div>
<div class="flex-center" class:rotate={verticalContent}>
<ExpandRightDouble />
</div>
<div class="flex-grow">
<SpaceSelect
_class={recruit.class.Vacancy}
spaceQuery={{ archived: false }}
spaceOptions={orgOptions}
label={recruit.string.Vacancy}
create={{
component: recruit.component.CreateVacancy,
label: recruit.string.CreateVacancy
}}
bind:value={_space}
on:change={(evt) => {
_space = evt.detail
}}
component={VacancyOrgPresenter}
componentProps={{ inline: true }}
>
<svelte:fragment slot="content">
<VacancyCard {vacancy} disabled={true} />
</svelte:fragment>
</SpaceSelect>
</div>
</div>
<svelte:fragment slot="pool">
{#if states.length > 0}
<Button
focusIndex={3}
width="min-content"
size="small"
kind="no-border"
bind:input={btn}
on:click={() => {
showPopup(
ColorPopup,
{ value: states, searchable: true, placeholder: ui.string.SearchDots },
btn,
(result) => {
if (result && result.id) {
selectedState = { ...result, _id: result.id, title: result.label }
}
manager.setFocusPos(3)
}
)
}}
>
<div slot="content" class="flex-row-center" class:empty={!selectedState}>
{#if selectedState}
<div class="color" style="background-color: {getPlatformColor(selectedState.color)}" />
<span class="label overflow-label">{selectedState.title}</span>
{:else}
<div class="color" />
<span class="label overflow-label"><Label label={presentation.string.NotSelected} /></span>
{/if}
</div>
</Button>
{/if}
</svelte:fragment>
</Card>
<style lang="scss">
.candidate-vacancy {
display: grid;
grid-template-columns: 3fr 1fr 3fr;
grid-template-rows: 1fr;
}
.rotate {
transform: rotate(90deg);
}
.color {
margin-right: 0.375rem;
width: 0.875rem;
height: 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
}
.label {
flex-grow: 1;
min-width: 0;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.empty {
.color {
border-color: var(--content-color);
}
.label {
color: var(--content-color);
}
&:hover .color {
border-color: var(--accent-color);
}
&:hover .label {
color: var(--accent-color);
}
}
.vacancyList {
padding: 1rem 1.5rem 1.25rem;
background-color: var(--board-card-bg-color);
border: 1px solid var(--divider-color);
border-radius: 0.5rem;
transition-property: box-shadow, background-color, border-color;
transition-timing-function: var(--timing-shadow);
transition-duration: 0.15s;
user-select: text;
min-width: 15rem;
min-height: 15rem;
&:hover {
background-color: var(--board-card-bg-hover);
border-color: var(--button-border-color);
box-shadow: var(--accent-shadow);
}
}
</style>

View File

@ -64,6 +64,8 @@ import VacancyList from './components/VacancyList.svelte'
import VacancyTemplateEditor from './components/VacancyTemplateEditor.svelte'
import MatchVacancy from './components/MatchVacancy.svelte'
import { MoveApplicant } from './actionImpl'
async function createOpinion (object: Doc): Promise<void> {
showPopup(CreateOpinion, { space: object.space, review: object._id })
}
@ -265,7 +267,8 @@ async function noneApplicant (filter: Filter, onUpdate: () => void): Promise<Obj
export default async (): Promise<Resources> => ({
actionImpl: {
CreateOpinion: createOpinion
CreateOpinion: createOpinion,
MoveApplicant
},
validator: {
ApplicantValidator: applicantValidator

View File

@ -117,7 +117,8 @@ export default mergeIds(recruitId, recruit, {
VacancyMatching: '' as IntlString,
Score: '' as IntlString,
Match: '' as IntlString,
PerformMatch: '' as IntlString
PerformMatch: '' as IntlString,
MoveApplication: '' as IntlString
},
space: {
CandidatesPublic: '' as Ref<Space>

View File

@ -64,7 +64,7 @@
<Icon icon={tracker.icon.Issues} size={'small'} />
</div>
{/if}
<span title={value?.title}>
<span class="select-text" title={value?.title}>
{title}
</span>
</span>

View File

@ -36,7 +36,7 @@
<span class="titlePresenter-container" class:with-margin={shouldUseMargin} title={value.title}>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="name overflow-label cursor-pointer"
class="name overflow-label cursor-pointer select-text"
style:max-width={showParent ? `${value.parents.length !== 0 ? 95 : 100}%` : '100%'}
on:click={handleIssueEditorOpened}>{value.title}</span
>

View File

@ -14,15 +14,16 @@
// limitations under the License.
-->
<script lang="ts">
import { Label, Button, Status as StatusControl } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import { Button, Label, Status as StatusControl } from '@hcengineering/ui'
import core, { AttachedDoc, Collection, Doc, Ref, Space, SortingOrder, Client, Class } from '@hcengineering/core'
import core, { Class, Client, Doc, Ref, SortingOrder, Space } from '@hcengineering/core'
import { getResource, OK, Resource, Status, translate } from '@hcengineering/platform'
import { SpaceSelect } from '@hcengineering/presentation'
import task, { calcRank, Task } from '@hcengineering/task'
import { createEventDispatcher } from 'svelte'
import view from '../plugin'
import task, { Task, calcRank } from '@hcengineering/task'
import { getResource, OK, Resource, Status, translate } from '@hcengineering/platform'
import { moveToSpace } from '../utils'
export let selected: Doc | Doc[]
$: docs = Array.isArray(selected) ? selected : [selected]
@ -45,19 +46,6 @@
$: _class && translate(_class, {}).then((res) => (classLabel = res.toLocaleLowerCase()))
async function move (doc: Doc): Promise<void> {
const attributes = hierarchy.getAllAttributes(doc._class)
for (const [name, attribute] of attributes) {
if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
const collection = attribute.type as Collection<AttachedDoc>
const allAttached = await client.findAll(collection.of, { attachedTo: doc._id })
for (const attached of allAttached) {
move(attached).catch((err) => console.log('failed to move', name, err))
}
}
}
const update: any = {
space: doc.space
}
const needStates = currentSpace ? hierarchy.isDerived(currentSpace._class, task.class.SpaceWithStates) : false
if (needStates) {
const state = await client.findOne(task.class.State, { space: doc.space })
@ -69,10 +57,14 @@
{ state: state._id },
{ sort: { rank: SortingOrder.Descending } }
)
update.state = state._id
update.rank = calcRank(lastOne, undefined)
await moveToSpace(client, doc, space, {
state: state._id,
rank: calcRank(lastOne, undefined)
})
} else {
await moveToSpace(client, doc, space)
}
client.updateDoc(doc._class, doc.space, doc._id, update)
dispatch('close')
}

View File

@ -20,14 +20,16 @@ import core, {
Client,
Collection,
Doc,
DocumentUpdate,
Hierarchy,
Lookup,
Obj,
Ref,
RefTo,
TxOperations,
ReverseLookup,
ReverseLookups
ReverseLookups,
Space,
TxOperations
} from '@hcengineering/core'
import type { IntlString } from '@hcengineering/platform'
import { getResource } from '@hcengineering/platform'
@ -36,8 +38,8 @@ import {
AnyComponent,
ErrorPresenter,
getCurrentLocation,
Location,
getPlatformColorForText,
Location,
locationToUrl
} from '@hcengineering/ui'
import type { BuildModelOptions, Viewlet } from '@hcengineering/view'
@ -598,3 +600,30 @@ export function cosinesim (A: number[], B: number[]): number {
const similarity = dotproduct / (mA * mB) // here you needed extra brackets
return similarity
}
/**
* @public
*/
export async function moveToSpace (
client: TxOperations,
doc: Doc,
space: Ref<Space>,
extra?: DocumentUpdate<any>
): Promise<void> {
const hierarchy = client.getHierarchy()
const attributes = hierarchy.getAllAttributes(doc._class)
for (const [name, attribute] of attributes) {
if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) {
const collection = attribute.type as Collection<AttachedDoc>
const allAttached = await client.findAll(collection.of, { attachedTo: doc._id })
for (const attached of allAttached) {
// Do not use extra for childs.
await moveToSpace(client, attached, space).catch((err) => console.log('failed to move', name, err))
}
}
}
await client.update(doc, {
space,
...extra
})
}