mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-13 21:27:46 +02:00
Merge branch 'staging-new' of https://github.com/hcengineering/platform
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
# Reusable action: set all @hcengineering package versions from a v tag ref
|
||||
# (e.g. refs/tags/v0.7.370 → 0.7.370) using common/scripts/bump.js.
|
||||
# Call only when github.ref is a v* tag (e.g. if: startsWith(github.ref, 'refs/tags/v')).
|
||||
name: 'Set package versions'
|
||||
description: 'Set all @hcengineering package versions from a v tag ref using bump.js'
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Git ref for the tag (e.g. github.ref, e.g. refs/tags/v0.7.370)'
|
||||
required: true
|
||||
outputs:
|
||||
version:
|
||||
description: 'Semver version derived from the tag (e.g. 0.7.370)'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- id: bump
|
||||
run: |
|
||||
VERSION="${INPUT_REF#refs/tags/v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
node common/scripts/bump.js "$VERSION"
|
||||
shell: bash
|
||||
@@ -77,11 +77,6 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Set package versions
|
||||
if: startsWith(github.event.inputs.ref || github.ref, 'refs/tags/v')
|
||||
uses: ./.github/actions/set-package-versions
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v5
|
||||
env:
|
||||
@@ -114,6 +109,14 @@ jobs:
|
||||
- name: Model version from git tags
|
||||
run: node common/scripts/install-run-rush.js model-version
|
||||
|
||||
- name: Verify package versions match tag
|
||||
if: startsWith(github.event.inputs.ref || github.ref, 'refs/tags/v')
|
||||
run: |
|
||||
REF="${{ github.event.inputs.ref || github.ref }}"
|
||||
VERSION="${REF#refs/tags/v}"
|
||||
VERSION="${VERSION#v}"
|
||||
node common/scripts/bump.js --check "$VERSION"
|
||||
|
||||
- name: Building...
|
||||
run: node common/scripts/install-run-rush.js build
|
||||
|
||||
@@ -969,4 +972,6 @@ jobs:
|
||||
run: |
|
||||
REF="${{ github.event.inputs.ref || github.ref }}"
|
||||
VERSION="${REF#refs/tags/v}"
|
||||
node common/scripts/bump.js "$VERSION" --publish
|
||||
VERSION="${VERSION#v}"
|
||||
node common/scripts/bump.js --check "$VERSION"
|
||||
node common/scripts/safe-publish.js
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
Changelog.
|
||||
|
||||
## [0.7.375] - 2026-02-23
|
||||
|
||||
* 🚀 FEATURES: · Add activity section in controlled doc ([#10457](https://github.com/hcengineering/platform/issues/10457)) · Add data converter plugin ([#10460](https://github.com/hcengineering/platform/issues/10460)) · *(ui)* Add Turkish language to settings popup ([#10441](https://github.com/hcengineering/platform/issues/10441)) · Turkish language in dev setup ([#10462](https://github.com/hcengineering/platform/issues/10462)) · Add import notifications ([#10464](https://github.com/hcengineering/platform/issues/10464)) · Guest users to upload attachments and use reactions ([#10469](https://github.com/hcengineering/platform/issues/10469)) · Add activity input for controlled docs ([#10470](https://github.com/hcengineering/platform/issues/10470)) · Ability to select multiple docs and to export to pdf ([#10468](https://github.com/hcengineering/platform/issues/10468)) · Headers legibility ([#10473](https://github.com/hcengineering/platform/issues/10473)) · Brazilian Portuguese translation ([#10478](https://github.com/hcengineering/platform/issues/10478)) · Add drive permissions ([#10489](https://github.com/hcengineering/platform/issues/10489)) · Add unlock card and section functionality with UI updates ([#10492](https://github.com/hcengineering/platform/issues/10492)) · To define different viewlet actions ([#10502](https://github.com/hcengineering/platform/issues/10502)) · Add ability to copy all data from cards/docs tables ([#10505](https://github.com/hcengineering/platform/issues/10505)) · Redesign workspace join ([#10507](https://github.com/hcengineering/platform/issues/10507)) · For guests to update notification settings ([#10517](https://github.com/hcengineering/platform/issues/10517)) · Add project breadcrumb ([#10515](https://github.com/hcengineering/platform/issues/10515)) · Implement process import/export functionality, enhance process … ([#10528](https://github.com/hcengineering/platform/issues/10528)) · Implement CancelSubProcess action, fix process execution flow. ([#10530](https://github.com/hcengineering/platform/issues/10530)) · Add ability to group by issues by project ([#10531](https://github.com/hcengineering/platform/issues/10531))
|
||||
* 🐛 BUG FIXES: · Okit.graphql is not a function ([#10442](https://github.com/hcengineering/platform/issues/10442)) · Custom attributes in markdown table ([#10445](https://github.com/hcengineering/platform/issues/10445)) · Relationship table diff/refresh ([#10446](https://github.com/hcengineering/platform/issues/10446)) · Handle youtu.be links ([#10458](https://github.com/hcengineering/platform/issues/10458)) · Calendar timezone day-shift bug ([#7048](https://github.com/hcengineering/platform/issues/7048)) ([#10459](https://github.com/hcengineering/platform/issues/10459)) · Mute account unauthorized error ([#10461](https://github.com/hcengineering/platform/issues/10461)) · Do not write analytics event when no token ([#10463](https://github.com/hcengineering/platform/issues/10463)) · Documents space export ([#10465](https://github.com/hcengineering/platform/issues/10465)) · Print layout ([#10472](https://github.com/hcengineering/platform/issues/10472)) · Use the error logging level for expected situations ([#10474](https://github.com/hcengineering/platform/issues/10474)) · Allow to open mermaid diagram in fullscreen ([#10477](https://github.com/hcengineering/platform/issues/10477)) · Rows order in relationsheep table compare ([#10479](https://github.com/hcengineering/platform/issues/10479)) · Custom attributes display in original view ([#10480](https://github.com/hcengineering/platform/issues/10480)) · Card formatting in markdown ([#10475](https://github.com/hcengineering/platform/issues/10475)) · Proper mermaid diagram size ([#10487](https://github.com/hcengineering/platform/issues/10487)) · Embed pdf via direct link ([#10491](https://github.com/hcengineering/platform/issues/10491)) · Secure blobs ([#10490](https://github.com/hcengineering/platform/issues/10490)) · Relationship table refresh ([#10494](https://github.com/hcengineering/platform/issues/10494)) · Issues layout ([#10498](https://github.com/hcengineering/platform/issues/10498)) · Exception during github issue id reading ([#10500](https://github.com/hcengineering/platform/issues/10500)) · Email notifications for export ([#10506](https://github.com/hcengineering/platform/issues/10506)) · Prevent datalake from error flood ([#10521](https://github.com/hcengineering/platform/issues/10521)) · Brazilian Portuguese translation ([#10522](https://github.com/hcengineering/platform/issues/10522)) · Datalake in dev setup does not support secure mode ([#10525](https://github.com/hcengineering/platform/issues/10525)) · Date formatting in md table ([#10523](https://github.com/hcengineering/platform/issues/10523)) · Bump with foundation packages ([#10536](https://github.com/hcengineering/platform/issues/10536)) · Process ([#10543](https://github.com/hcengineering/platform/issues/10543))
|
||||
* 🚜 REFACTOR: · One metadata interface ([#10444](https://github.com/hcengineering/platform/issues/10444))
|
||||
* ⚙️ MISCELLANEOUS TASKS: · Changelog · Changelog ([#10439](https://github.com/hcengineering/platform/issues/10439)) · Mail URL from export pod ([#10476](https://github.com/hcengineering/platform/issues/10476)) · Etc/hosts path for Windows ([#10482](https://github.com/hcengineering/platform/issues/10482)) · Space security and permissions ([#10529](https://github.com/hcengineering/platform/issues/10529)) · Kvs and pulse versions ([#10539](https://github.com/hcengineering/platform/issues/10539)) · And publish npm packages for v tags ([#10542](https://github.com/hcengineering/platform/issues/10542))
|
||||
* FIX: · Images not rendering on GitHub side of GitHub integration ([#10520](https://github.com/hcengineering/platform/issues/10520)) · Show print actions for individual documents ([#10484](https://github.com/hcengineering/platform/issues/10484)) · Password signup crashes with JSON parse error when MAIL_URL is configured ([#10519](https://github.com/hcengineering/platform/issues/10519))
|
||||
* QFIX: · Allow notifications for guests with collab security ([#10510](https://github.com/hcengineering/platform/issues/10510))
|
||||
* QFIX: · Remove unused ui dependencies ([#10511](https://github.com/hcengineering/platform/issues/10511))
|
||||
|
||||
## [0.7.353] - 2026-01-24
|
||||
|
||||
* 🚀 FEATURES: · Link preview service ([#10424](https://github.com/hcengineering/platform/issues/10424)) · Add confirmation modal for table refresh ([#10428](https://github.com/hcengineering/platform/issues/10428)) · Guest update profile (avatar, name etc) ([#10429](https://github.com/hcengineering/platform/issues/10429))
|
||||
|
||||
@@ -247,6 +247,14 @@
|
||||
"safeForSimultaneousRushProcesses": true,
|
||||
"shellCommand": "./common/scripts/docker.sh"
|
||||
},
|
||||
{
|
||||
"commandKind": "global",
|
||||
"name": "docker:patch",
|
||||
"summary": "Build and push docker images with a custom version",
|
||||
"description": "Allows building and pushing docker images with a manually specified version tag. Usage: rush docker:patch --version <version> [rush-args]",
|
||||
"safeForSimultaneousRushProcesses": true,
|
||||
"shellCommand": "./common/scripts/docker_patch.sh"
|
||||
},
|
||||
{
|
||||
"commandKind": "global",
|
||||
"name": "docker-server",
|
||||
@@ -424,6 +432,22 @@
|
||||
"longName": "--branch",
|
||||
"description": "Force formatting of branch",
|
||||
"associatedCommands": ["fast-format"]
|
||||
},
|
||||
{
|
||||
"parameterKind": "string",
|
||||
"argumentName": "VERSION",
|
||||
"required": false,
|
||||
"longName": "--version",
|
||||
"description": "The version tag to use for docker images",
|
||||
"associatedCommands": ["docker:patch"]
|
||||
},
|
||||
{
|
||||
"parameterKind": "string",
|
||||
"argumentName": "PLATFORM",
|
||||
"required": false,
|
||||
"longName": "--platform",
|
||||
"description": "The target platform for docker build (e.g., linux/amd64). Defaults to linux/amd64.",
|
||||
"associatedCommands": ["docker:patch"]
|
||||
}
|
||||
// {
|
||||
// /**
|
||||
|
||||
Generated
+1016
-1482
File diff suppressed because it is too large
Load Diff
+31
-9
@@ -77,22 +77,25 @@ function fix (name) {
|
||||
}
|
||||
|
||||
function main () {
|
||||
const args = process.argv
|
||||
const argv = process.argv.slice(2)
|
||||
|
||||
const doFix = args.includes('--fix')
|
||||
const doPublish = args.includes('--publish')
|
||||
const doFix = argv.includes('--fix')
|
||||
const doPublish = argv.includes('--publish')
|
||||
const doCheck = argv.includes('--check')
|
||||
|
||||
const positional = argv.filter((a) => !a.startsWith('--'))
|
||||
const version = positional[0]
|
||||
|
||||
const version = args.reverse().shift()
|
||||
if (version === undefined || version === '') {
|
||||
console.log('usage: node bump.js [--publish] <version>')
|
||||
console.log('usage: node bump.js [--check] [--fix] [--publish] <version>')
|
||||
return
|
||||
}
|
||||
if( !/^(\d+\.)?(\d+\.)?(\*|\d+)$/.test(version)) {
|
||||
if (!/^(\d+\.)?(\d+\.)?(\*|\d+)$/.test(version)) {
|
||||
console.log('Invalid <version>', version, ' should be xx.xx.xx')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('bump version ...', version)
|
||||
console.log(doCheck ? 'check versions ...' : 'bump version ...', version)
|
||||
|
||||
const output = execSync('node common/scripts/install-run-rush.js list -p --json', { encoding: 'utf-8', cwd: repoRoot })
|
||||
const lines = output.split('\n')
|
||||
@@ -112,14 +115,33 @@ function main () {
|
||||
fillPackages(config)
|
||||
|
||||
const packageNames = Object.keys(packages)
|
||||
|
||||
if (doCheck) {
|
||||
let ok = true
|
||||
for (const packageName of packageNames) {
|
||||
const json = jsons[packageName]
|
||||
if (json === undefined) continue
|
||||
if (json.version !== version) {
|
||||
console.error('Version mismatch:', packageName, 'expected', version, 'got', json.version)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if (!ok) {
|
||||
console.error('Some @hcengineering package versions do not match', version)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('All @hcengineering package versions match', version)
|
||||
return
|
||||
}
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
bumpPackage(packageName, version)
|
||||
}
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const package = packages[packageName]
|
||||
const pkg = packages[packageName]
|
||||
if (jsons[packageName] === undefined) continue
|
||||
const file = path.join(package.path, 'package.json')
|
||||
const file = path.join(pkg.path, 'package.json')
|
||||
const res = JSON.stringify(jsons[packageName], undefined, 2)
|
||||
fs.writeFileSync(file, res + '\n')
|
||||
}
|
||||
|
||||
@@ -57,5 +57,6 @@ else
|
||||
--to @hcengineering/pod-billing \
|
||||
--to @hcengineering/pod-process \
|
||||
--to @hcengineering/pod-rating \
|
||||
--to @hcengineering/pod-payment
|
||||
--to @hcengineering/pod-payment \
|
||||
--to @hcengineering/pod-worker
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
version=$(git rev-parse HEAD)
|
||||
version=${DOCKER_VERSION:-$(git rev-parse HEAD)}
|
||||
|
||||
# Check for cleanup flag from environment
|
||||
cleanup=false
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script allows building and pushing docker images with a custom version tag.
|
||||
# It can be run interactively or with the --version flag.
|
||||
|
||||
VERSION=""
|
||||
PLATFORM="linux/amd64"
|
||||
REMAINING_ARGS=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--version)
|
||||
VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--platform)
|
||||
PLATFORM="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
REMAINING_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
read -p "Enter version tag for docker images: " VERSION
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Error: Version tag is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DOCKER_VERSION="$VERSION"
|
||||
export DOCKER_EXTRA="--platform=$PLATFORM"
|
||||
echo "Starting docker build and push for version: $DOCKER_VERSION ($PLATFORM)"
|
||||
|
||||
# We use 'node common/scripts/install-run-rush.js' to ensure we use the right rush version
|
||||
node "$(dirname "$0")/install-run-rush.js" docker:build "${REMAINING_ARGS[@]}"
|
||||
node "$(dirname "$0")/install-run-rush.js" docker:push "${REMAINING_ARGS[@]}"
|
||||
|
||||
echo "Docker patch completed for version: $DOCKER_VERSION"
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
version=$(git describe --tags --abbrev=0)
|
||||
version=${DOCKER_VERSION:-$(git describe --tags --abbrev=0)}
|
||||
rev_version=$(git rev-parse HEAD)
|
||||
|
||||
if [ "x$2" = "xstaging" ]
|
||||
|
||||
@@ -43,8 +43,24 @@ function checkPackageExists(packageName, version) {
|
||||
*/
|
||||
function getPublishablePackages(includePattern) {
|
||||
try {
|
||||
const output = execSync('rush list -p --json', { encoding: 'utf-8' })
|
||||
const config = JSON.parse(output)
|
||||
const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim()
|
||||
const output = execSync('node common/scripts/install-run-rush.js list -p --json', {
|
||||
encoding: 'utf-8',
|
||||
cwd: repoRoot
|
||||
})
|
||||
const lines = output.split('\n')
|
||||
let jsonStart = -1
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim().startsWith('{')) {
|
||||
jsonStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if (jsonStart === -1) {
|
||||
console.error('Could not find JSON output from rush list')
|
||||
return []
|
||||
}
|
||||
const config = JSON.parse(lines.slice(jsonStart).join('\n'))
|
||||
|
||||
return config.projects.filter((project) => {
|
||||
// Check if package should be published according to rush.json
|
||||
|
||||
+17
-1
@@ -79,7 +79,7 @@ services:
|
||||
- --mode dev-container
|
||||
- --smp 1
|
||||
- --default-log-level=info
|
||||
- --memory 256M
|
||||
- --memory 512M
|
||||
container_name: redpanda
|
||||
volumes:
|
||||
- redpanda:/var/lib/redpanda/data
|
||||
@@ -594,6 +594,22 @@ services:
|
||||
- QUEUE_CONFIG=${QUEUE_CONFIG}
|
||||
- QUEUE_REGION=cockroach
|
||||
restart: unless-stopped
|
||||
time-machine:
|
||||
image: hardcoreeng/worker
|
||||
extra_hosts:
|
||||
- 'huly.local:host-gateway'
|
||||
depends_on:
|
||||
redpanda:
|
||||
condition: service_started
|
||||
account:
|
||||
condition: service_started
|
||||
cockroach:
|
||||
condition: service_started
|
||||
environment:
|
||||
- DB_URL=${DB_CR_URL}
|
||||
- QUEUE_CONFIG=${QUEUE_CONFIG}
|
||||
- QUEUE_REGION=cockroach
|
||||
restart: unless-stopped
|
||||
# translate:
|
||||
# image: hardcoreeng/translate
|
||||
# extra_hosts:
|
||||
|
||||
@@ -436,7 +436,10 @@ export abstract class TxProcessor implements WithTx {
|
||||
}
|
||||
|
||||
static txHasUpdate<T extends Doc>(tx: TxUpdateDoc<T>, attribute: string): boolean {
|
||||
const ops = tx.operations
|
||||
return TxProcessor.hasUpdate(tx.operations, attribute)
|
||||
}
|
||||
|
||||
static hasUpdate<T extends Doc>(ops: DocumentUpdate<T>, attribute: string): boolean {
|
||||
if ((ops as any)[attribute] !== undefined) return true
|
||||
for (const op in ops) {
|
||||
if (op.startsWith('$')) {
|
||||
|
||||
@@ -19,7 +19,9 @@ export enum QueueTopic {
|
||||
CalendarEventCUD = 'calendarEventCUD',
|
||||
|
||||
// A topic about process events
|
||||
Process = 'process'
|
||||
Process = 'process',
|
||||
|
||||
TimeMachine = 'timeMachine'
|
||||
}
|
||||
|
||||
export interface ConsumerHandle {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import activity from '@hcengineering/activity'
|
||||
import activity, { type ActivityMessageControl } from '@hcengineering/activity'
|
||||
import { type Role, type Card } from '@hcengineering/card'
|
||||
import {
|
||||
AvatarType,
|
||||
@@ -340,6 +340,13 @@ export function createModel (builder: Builder): void {
|
||||
preposition: contact.string.For
|
||||
})
|
||||
|
||||
// Prevent leaking private social identifiers (emails, external handles) via activity updates.
|
||||
builder.createDoc<ActivityMessageControl<Person>>(activity.class.ActivityMessageControl, core.space.Model, {
|
||||
objectClass: contact.class.Person,
|
||||
skip: [],
|
||||
skipFields: ['socialIds']
|
||||
})
|
||||
|
||||
builder.mixin(contact.mixin.Employee, core.class.Class, activity.mixin.ActivityDoc, {
|
||||
preposition: contact.string.For
|
||||
})
|
||||
|
||||
@@ -199,4 +199,32 @@ export function defineMethods (builder: Builder): void {
|
||||
},
|
||||
process.method.UnlockSection
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
process.class.Method,
|
||||
core.space.Model,
|
||||
{
|
||||
label: process.string.LockField,
|
||||
objectClass: card.class.Card,
|
||||
editor: process.component.LockFieldEditor,
|
||||
presenter: process.component.LockFieldPresenter,
|
||||
requiredParams: ['value'],
|
||||
createdContext: null
|
||||
},
|
||||
process.method.LockField
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
process.class.Method,
|
||||
core.space.Model,
|
||||
{
|
||||
label: process.string.UnlockField,
|
||||
objectClass: card.class.Card,
|
||||
editor: process.component.LockFieldEditor,
|
||||
presenter: process.component.UnLockFieldPresenter,
|
||||
requiredParams: ['value'],
|
||||
createdContext: null
|
||||
},
|
||||
process.method.UnlockField
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@ export class TProcess extends TDoc implements Process {
|
||||
@Prop(TypeBoolean(), process.string.StartAutomatically)
|
||||
autoStart: boolean | undefined
|
||||
|
||||
@Prop(TypeBoolean(), process.string.AutomationOnly)
|
||||
automationOnly: boolean | undefined
|
||||
|
||||
context!: Record<ContextId, ProcessContext>
|
||||
}
|
||||
|
||||
@@ -218,6 +221,9 @@ export class TApproveRequest extends TProcessToDo implements ApproveRequest {
|
||||
group!: string
|
||||
|
||||
card!: Ref<Card>
|
||||
|
||||
@Prop(TypeString(), process.string.ActionType)
|
||||
actionType?: 'approve' | 'review'
|
||||
}
|
||||
|
||||
@Model(process.class.Method, core.class.Doc, DOMAIN_MODEL)
|
||||
@@ -475,7 +481,17 @@ export function createModel (builder: Builder): void {
|
||||
strict: true
|
||||
},
|
||||
config: [
|
||||
{
|
||||
key: 'execution',
|
||||
label: process.string.Process,
|
||||
presenter: process.component.ExecutionRefPresenter
|
||||
},
|
||||
'user',
|
||||
{
|
||||
key: 'actionType',
|
||||
label: process.string.ActionType,
|
||||
presenter: process.component.ActionTypePresenter
|
||||
},
|
||||
{
|
||||
key: '',
|
||||
presenter: view.component.GrowPresenter,
|
||||
@@ -492,6 +508,10 @@ export function createModel (builder: Builder): void {
|
||||
process.viewlet.CardRequests
|
||||
)
|
||||
|
||||
builder.mixin(process.class.ApproveRequest, core.class.Class, view.mixin.IgnoreActions, {
|
||||
actions: [view.action.Delete]
|
||||
})
|
||||
|
||||
builder.createDoc(
|
||||
view.class.Viewlet,
|
||||
core.space.Model,
|
||||
|
||||
@@ -119,7 +119,7 @@ export function defineTriggers (builder: Builder): void {
|
||||
requiredParams: [],
|
||||
checkFunction: process.triggerCheck.FieldChangedCheck,
|
||||
init: false,
|
||||
auto: true
|
||||
auto: false
|
||||
},
|
||||
process.trigger.WhenFieldChanges
|
||||
)
|
||||
|
||||
@@ -66,7 +66,7 @@ export function createModel (builder: Builder): void {
|
||||
})
|
||||
|
||||
builder.mixin(process.trigger.WhenFieldChanges, process.class.Trigger, serverProcess.mixin.TriggerImpl, {
|
||||
preventRollback: true,
|
||||
rollbackFunc: serverProcess.rollbacks.FieldChangedRollback, // set to null for now
|
||||
serverCheckFunc: serverProcess.func.FieldChangedCheck
|
||||
})
|
||||
|
||||
@@ -155,6 +155,14 @@ export function createModel (builder: Builder): void {
|
||||
func: serverProcess.func.UnlockSection
|
||||
})
|
||||
|
||||
builder.mixin(process.method.LockField, process.class.Method, serverProcess.mixin.MethodImpl, {
|
||||
func: serverProcess.func.LockField
|
||||
})
|
||||
|
||||
builder.mixin(process.method.UnlockField, process.class.Method, serverProcess.mixin.MethodImpl, {
|
||||
func: serverProcess.func.UnlockField
|
||||
})
|
||||
|
||||
builder.mixin(process.function.FirstValue, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
|
||||
func: serverProcess.transform.FirstValue
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type IntegrationKind
|
||||
} from '@hcengineering/core'
|
||||
import exportPlugin from '@hcengineering/export'
|
||||
import { Mixin, Model, UX, type Builder } from '@hcengineering/model'
|
||||
import { Mixin, Model, Prop, TypeRecord, UX, type Builder } from '@hcengineering/model'
|
||||
import core, { TClass, TConfiguration, TDoc } from '@hcengineering/model-core'
|
||||
import view, { createAction } from '@hcengineering/model-view'
|
||||
import notification from '@hcengineering/notification'
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type IntegrationType,
|
||||
type InviteSettings,
|
||||
type OfficeSettings,
|
||||
type RoleCapabilitySettings,
|
||||
type SettingsCategory,
|
||||
type SpaceTypeCreator,
|
||||
type SpaceTypeEditor,
|
||||
@@ -112,6 +113,15 @@ export class TInviteSettings extends TConfiguration implements InviteSettings {
|
||||
expirationTime!: number
|
||||
emailMask!: string
|
||||
limit!: number
|
||||
defaultInviteRole!: AccountRole
|
||||
inviteLinkGeneratorRoles!: AccountRole[]
|
||||
}
|
||||
|
||||
@Model(setting.class.RoleCapabilitySettings, core.class.Configuration, DOMAIN_SETTING)
|
||||
@UX(setting.string.RoleCapabilitySettings)
|
||||
export class TRoleCapabilitySettings extends TConfiguration implements RoleCapabilitySettings {
|
||||
@Prop(TypeRecord(), setting.string.RoleCapabilitySettings)
|
||||
roleByCapability!: Record<string, AccountRole[]>
|
||||
}
|
||||
|
||||
@Model(setting.class.OfficeSettings, core.class.Configuration, DOMAIN_SETTING)
|
||||
@@ -146,6 +156,7 @@ export function createModel (builder: Builder): void {
|
||||
TEditable,
|
||||
TUserMixin,
|
||||
TInviteSettings,
|
||||
TRoleCapabilitySettings,
|
||||
TOfficeSettings,
|
||||
TWorkspaceSetting,
|
||||
TSpaceTypeEditor,
|
||||
|
||||
@@ -72,7 +72,8 @@ export default mergeIds(trackerId, tracker, {
|
||||
IssueKanban: '' as Ref<Viewlet>,
|
||||
MilestoneList: '' as Ref<Viewlet>,
|
||||
ComponentList: '' as Ref<Viewlet>,
|
||||
ProjectList: '' as Ref<Viewlet>
|
||||
ProjectList: '' as Ref<Viewlet>,
|
||||
ProjectListGrouped: '' as Ref<Viewlet>
|
||||
},
|
||||
ids: {
|
||||
TrackerNotificationGroup: '' as Ref<NotificationGroup>,
|
||||
|
||||
@@ -20,7 +20,7 @@ import core from '@hcengineering/model-core'
|
||||
import task from '@hcengineering/model-task'
|
||||
import view, { showColorsViewOption } from '@hcengineering/model-view'
|
||||
import tags from '@hcengineering/tags'
|
||||
import { type BuildModelKey, type ViewOptionsModel } from '@hcengineering/view'
|
||||
import { type ViewOptionModel, type BuildModelKey, type ViewOptionsModel } from '@hcengineering/view'
|
||||
import tracker from './plugin'
|
||||
|
||||
export const issuesOptions = (kanban: boolean): ViewOptionsModel => ({
|
||||
@@ -543,14 +543,42 @@ export function defineViewlets (builder: Builder): void {
|
||||
tracker.viewlet.ComponentList
|
||||
)
|
||||
|
||||
const hideArchivedOption: ViewOptionModel = {
|
||||
key: 'hideArchived',
|
||||
type: 'toggle',
|
||||
defaultValue: false,
|
||||
actionTarget: 'options',
|
||||
action: view.function.HideArchived,
|
||||
label: view.string.HideArchived
|
||||
}
|
||||
|
||||
const tableOptions: ViewOptionsModel = {
|
||||
groupBy: [],
|
||||
orderBy: [],
|
||||
other: [hideArchivedOption]
|
||||
}
|
||||
|
||||
const projectListOptions: ViewOptionsModel = {
|
||||
groupBy: ['createdBy', 'modifiedBy'],
|
||||
orderBy: [
|
||||
['name', SortingOrder.Ascending],
|
||||
['identifier', SortingOrder.Ascending],
|
||||
['modifiedOn', SortingOrder.Descending],
|
||||
['createdOn', SortingOrder.Descending]
|
||||
],
|
||||
other: [hideArchivedOption]
|
||||
}
|
||||
|
||||
builder.createDoc(
|
||||
view.class.Viewlet,
|
||||
core.space.Model,
|
||||
{
|
||||
attachTo: tracker.class.Project,
|
||||
descriptor: view.viewlet.Table,
|
||||
viewOptions: tableOptions,
|
||||
configOptions: {
|
||||
hiddenKeys: ['identifier', 'name', 'description']
|
||||
hiddenKeys: ['identifier', 'name', 'description'],
|
||||
sortable: true
|
||||
},
|
||||
config: [
|
||||
{
|
||||
@@ -578,6 +606,44 @@ export function defineViewlets (builder: Builder): void {
|
||||
tracker.viewlet.ProjectList
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
view.class.Viewlet,
|
||||
core.space.Model,
|
||||
{
|
||||
attachTo: tracker.class.Project,
|
||||
descriptor: view.viewlet.List,
|
||||
viewOptions: projectListOptions,
|
||||
configOptions: {
|
||||
strict: true,
|
||||
hiddenKeys: ['identifier', 'name', 'description']
|
||||
},
|
||||
config: [
|
||||
{
|
||||
key: '',
|
||||
presenter: tracker.component.ProjectPresenter,
|
||||
props: {
|
||||
openIssues: true,
|
||||
shouldUseMargin: true
|
||||
}
|
||||
},
|
||||
'members',
|
||||
{
|
||||
key: 'defaultAssignee',
|
||||
props: { kind: 'list' }
|
||||
},
|
||||
{
|
||||
key: 'modifiedOn',
|
||||
presenter: tracker.component.ModificationDatePresenter,
|
||||
displayProps: { fixed: 'right', dividerBefore: true }
|
||||
}
|
||||
],
|
||||
options: {
|
||||
showArchived: true
|
||||
}
|
||||
},
|
||||
tracker.viewlet.ProjectListGrouped
|
||||
)
|
||||
|
||||
const milestoneOptions: ViewOptionsModel = {
|
||||
groupBy: ['status', 'createdBy', 'modifiedBy'],
|
||||
orderBy: [
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { onDestroy } from 'svelte'
|
||||
import EmbeddedPDF from './EmbeddedPDF.svelte'
|
||||
import Loading from './Loading.svelte'
|
||||
|
||||
export let src: string
|
||||
export let name: string
|
||||
export let fit: boolean = false
|
||||
export let css: string | undefined = undefined
|
||||
|
||||
let iframeSrc: string | undefined
|
||||
|
||||
async function loadFile (src: string): Promise<void> {
|
||||
if (iframeSrc !== undefined) {
|
||||
URL.revokeObjectURL(iframeSrc)
|
||||
iframeSrc = undefined
|
||||
}
|
||||
|
||||
const response = await fetch(src)
|
||||
const blob = await response.blob()
|
||||
iframeSrc = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
$: void loadFile(src)
|
||||
|
||||
onDestroy(() => {
|
||||
if (iframeSrc !== undefined) {
|
||||
URL.revokeObjectURL(iframeSrc)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if iframeSrc}
|
||||
<EmbeddedPDF src={iframeSrc} {name} {fit} {css} />
|
||||
{:else}
|
||||
<Loading />
|
||||
{/if}
|
||||
@@ -284,6 +284,7 @@ export { default as CodeForm } from './components/CodeForm.svelte'
|
||||
export { default as CodeInput } from './components/CodeInput.svelte'
|
||||
export { default as TimeLeft } from './components/TimeLeft.svelte'
|
||||
export { default as SectionEmpty } from './components/SectionEmpty.svelte'
|
||||
export { default as EmbeddedHTML } from './components/EmbeddedHTML.svelte'
|
||||
export { default as EmbeddedPDF } from './components/EmbeddedPDF.svelte'
|
||||
export { default as NestedMenu } from './components/NestedMenu.svelte'
|
||||
export { default as NestedDropdown } from './components/NestedDropdown.svelte'
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
"build:docs": "api-extractor run --local",
|
||||
"format": "format src",
|
||||
"svelte-check": "do-svelte-check",
|
||||
"test": "jest --passWithNoTests --silent",
|
||||
"_phase:svelte-check": "do-svelte-check",
|
||||
"build:watch": "compile ui",
|
||||
"_phase:build": "compile ui",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
"_phase:validate": "compile validate",
|
||||
"_phase:test": "jest --passWithNoTests --silent"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hcengineering/platform-rig": "workspace:^0.7.19",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// Copyright © 2026 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 { AttributeModel } from '@hcengineering/view'
|
||||
import cardPlugin, { type Card } from '@hcengineering/card'
|
||||
import { ClassifierKind, type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core'
|
||||
import { isTagsColumn, formatCardTagsForMarkdown, formatTagValue } from '../tagFormatter'
|
||||
|
||||
jest.mock('@hcengineering/platform', () => {
|
||||
const actual = jest.requireActual('@hcengineering/platform')
|
||||
return {
|
||||
...actual,
|
||||
translate: jest.fn(async (str: unknown) => String(str))
|
||||
}
|
||||
})
|
||||
|
||||
describe('tagFormatter.isTagsColumn', () => {
|
||||
it('returns true for Tags column with empty key and Tags label', () => {
|
||||
const attr = {
|
||||
key: '',
|
||||
sortingKey: '',
|
||||
_class: '69659920d7268020fb69db86',
|
||||
label: 'card:string:Tags',
|
||||
props: {
|
||||
showType: false
|
||||
},
|
||||
collectionAttr: false,
|
||||
isLookup: false
|
||||
} as unknown as AttributeModel
|
||||
|
||||
expect(isTagsColumn(attr)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for explicit tags key', () => {
|
||||
const attr = {
|
||||
key: 'tags',
|
||||
sortingKey: 'tags',
|
||||
_class: 'card:class:Card',
|
||||
label: 'card:string:Tags',
|
||||
collectionAttr: false,
|
||||
isLookup: false
|
||||
} as unknown as AttributeModel
|
||||
|
||||
expect(isTagsColumn(attr)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for lookup-attached tags column', () => {
|
||||
const attr = {
|
||||
key: '$lookup.attachedTo',
|
||||
sortingKey: '',
|
||||
_class: 'card:class:Card',
|
||||
label: 'card:string:Tags',
|
||||
displayProps: {
|
||||
key: 'tags'
|
||||
},
|
||||
collectionAttr: false,
|
||||
isLookup: true
|
||||
} as unknown as AttributeModel
|
||||
|
||||
expect(isTagsColumn(attr)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-tags attribute', () => {
|
||||
const attr = {
|
||||
key: 'title',
|
||||
sortingKey: 'title',
|
||||
_class: 'card:class:Card',
|
||||
label: 'card:string:Title',
|
||||
collectionAttr: false,
|
||||
isLookup: false
|
||||
} as unknown as AttributeModel
|
||||
|
||||
expect(isTagsColumn(attr)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagFormatter.formatCardTagsForMarkdown', () => {
|
||||
it('formats card tags as comma-separated labels', async () => {
|
||||
const card = {
|
||||
_class: cardPlugin.class.Card
|
||||
} as unknown as Card
|
||||
|
||||
const getParentClass = jest.fn(() => 'parent:class:Card')
|
||||
const getDescendants = jest.fn(() => ['mixin:TagA', 'mixin:TagB'])
|
||||
const getClass = jest.fn((ref: Ref<Class<Doc>>) => {
|
||||
if (ref === 'mixin:TagA') {
|
||||
return { _id: ref, kind: ClassifierKind.MIXIN, label: 'Tag A' }
|
||||
}
|
||||
if (ref === 'mixin:TagB') {
|
||||
return { _id: ref, kind: ClassifierKind.MIXIN, label: 'Tag B' }
|
||||
}
|
||||
return { _id: ref, kind: ClassifierKind.CLASS, label: 'Other' }
|
||||
})
|
||||
|
||||
const hierarchy = {
|
||||
getParentClass,
|
||||
getDescendants,
|
||||
getClass,
|
||||
hasMixin: () => true
|
||||
} as unknown as Hierarchy
|
||||
|
||||
const result = await formatCardTagsForMarkdown(card, hierarchy, 'en')
|
||||
expect(result).toBe('Tag A, Tag B')
|
||||
expect(getParentClass).toHaveBeenCalledWith(card._class)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tagFormatter.formatTagValue', () => {
|
||||
it('uses card tag formatter when called for card tags column', async () => {
|
||||
const card = {
|
||||
_class: cardPlugin.class.Card
|
||||
} as unknown as Card
|
||||
|
||||
const attr = {
|
||||
key: 'tags',
|
||||
sortingKey: 'tags',
|
||||
_class: 'card:class:Card',
|
||||
label: 'card:string:Tags',
|
||||
collectionAttr: false,
|
||||
isLookup: false
|
||||
} as unknown as AttributeModel
|
||||
|
||||
const hierarchy = {
|
||||
isDerived: jest.fn((cls: Ref<Class<Doc>>, target: Ref<Class<Doc>>) => cls === target),
|
||||
getParentClass: jest.fn(() => 'parent:class:Card'),
|
||||
getDescendants: jest.fn(() => ['mixin:TagA']),
|
||||
getClass: jest.fn((ref: Ref<Class<Doc>>) => ({
|
||||
_id: ref,
|
||||
kind: ClassifierKind.MIXIN,
|
||||
label: 'Tag A'
|
||||
})),
|
||||
hasMixin: jest.fn(() => true)
|
||||
} as unknown as Hierarchy
|
||||
|
||||
const result = await formatTagValue(attr, card as unknown as Doc, hierarchy, card._class as Ref<Class<Doc>>, 'en')
|
||||
expect(result).toBe('Tag A')
|
||||
})
|
||||
|
||||
it('formats Tag-derived doc by title when not a tags column', async () => {
|
||||
const tagDoc = {
|
||||
_class: cardPlugin.class.Tag,
|
||||
title: 'Important'
|
||||
} as unknown as Doc
|
||||
|
||||
const attr = {
|
||||
key: 'title',
|
||||
sortingKey: 'title',
|
||||
_class: cardPlugin.class.Tag,
|
||||
label: 'card:string:TagTitle',
|
||||
collectionAttr: false,
|
||||
isLookup: false
|
||||
} as unknown as AttributeModel
|
||||
|
||||
const hierarchy = {
|
||||
isDerived: jest.fn((cls: Ref<Class<Doc>>, target: Ref<Class<Doc>>) => cls === target)
|
||||
} as unknown as Hierarchy
|
||||
|
||||
const result = await formatTagValue(attr, tagDoc, hierarchy, tagDoc._class as any, 'en')
|
||||
expect(result).toBe('Important')
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ import { type AttributeModel } from '@hcengineering/view'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { isIntlString } from '@hcengineering/converter-resources'
|
||||
import { getCardIds, getCardVersion } from './cardUtils'
|
||||
import { formatCardTagsForMarkdown, isTagsColumn } from './tagFormatter'
|
||||
|
||||
/**
|
||||
* Cache for MasterTag ID -> label mappings to reduce database calls
|
||||
@@ -111,6 +112,11 @@ export async function formatCardValue (
|
||||
|
||||
const cardDoc = card as unknown as Record<string, unknown>
|
||||
|
||||
// Handle tags column - format as comma-separated tag labels (same as CardTagsColored)
|
||||
if (isTagsColumn(attr)) {
|
||||
return await formatCardTagsForMarkdown(card as Card, hierarchy, language)
|
||||
}
|
||||
|
||||
if (attr.key === '') {
|
||||
const labelStr = typeof attr.label === 'string' ? attr.label : ''
|
||||
if (labelStr.startsWith('custom')) {
|
||||
@@ -124,7 +130,9 @@ export async function formatCardValue (
|
||||
}
|
||||
|
||||
// Handle _class field (MasterTag/Type) - format MasterTag ID to label
|
||||
if (attr.key === '_class') {
|
||||
// Key can be '_class' or '$lookup.attachedTo._class' (e.g. in relationship tables); doc is already displayDoc
|
||||
const isClassAttr = attr.key === '_class' || attr.key === '$lookup.attachedTo._class' || attr.key.endsWith('._class')
|
||||
if (isClassAttr) {
|
||||
const classValue: unknown = cardDoc._class
|
||||
if (classValue !== undefined && classValue !== null) {
|
||||
// Check if MasterTag is in lookup
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
{_class}
|
||||
{object}
|
||||
{showHeader}
|
||||
readonly={readonly || !canChange(key.attr, $permissionsStore)}
|
||||
readonly={readonly || !canChange(key.attr, $permissionsStore) || object.readonlyFields?.includes(key.key)}
|
||||
withIcon
|
||||
on:update
|
||||
/>
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
}
|
||||
})
|
||||
|
||||
$: _readonly = (readonly || doc?.readonly) ?? false
|
||||
$: _readonly = (readonly || doc?.readonly || doc?.readonlyFields?.includes('title')) ?? false
|
||||
$: updatePermissionForbidden = doc && !canChangeDoc(doc?._class, doc?.space, $permissionsStore)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -204,6 +204,8 @@
|
||||
const ref = sectionRef[messagesId]
|
||||
if (ref?.scrollDown != null) {
|
||||
ref.scrollDown()
|
||||
} else {
|
||||
console.warn('scrollDown not implemented for section', messagesId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,9 +27,25 @@
|
||||
export let active: boolean = false
|
||||
export let isDefault: boolean = false
|
||||
export let onRenderTopChange: (active: boolean) => void
|
||||
|
||||
let messagesSection: MessagesSection | undefined
|
||||
|
||||
// Expose scrolling/editing helpers so the parent TOC component can control this section.
|
||||
export function scrollDown (): void {
|
||||
messagesSection?.scrollDown()
|
||||
}
|
||||
|
||||
export function canScrollDown (): boolean {
|
||||
return messagesSection?.canScrollDown() ?? false
|
||||
}
|
||||
|
||||
export function editLastMessage (): void {
|
||||
messagesSection?.editLastMessage()
|
||||
}
|
||||
</script>
|
||||
|
||||
<MessagesSection
|
||||
bind:this={messagesSection}
|
||||
{doc}
|
||||
{readonly}
|
||||
{isContextLoaded}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// Copyright © 2026 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 Class, ClassifierKind, type Doc, type Hierarchy, type Ref } from '@hcengineering/core'
|
||||
import { translate, type IntlString } from '@hcengineering/platform'
|
||||
import cardPlugin, { type Card } from '@hcengineering/card'
|
||||
import { type AttributeModel } from '@hcengineering/view'
|
||||
|
||||
function isIntlString (value: unknown): value is IntlString {
|
||||
return typeof value === 'string' || (typeof value === 'object' && value !== null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the attribute corresponds to the tags column (CardTagsColored).
|
||||
*
|
||||
* Supports:
|
||||
* - key === 'tags' (explicit tags attribute)
|
||||
* - key === '$lookup.attachedTo' with displayProps.key === 'tags'
|
||||
* - key === '' with label ending in ':Tags' (e.g. system viewlets configured with CardTagsColored)
|
||||
*/
|
||||
export function isTagsColumn (attr: AttributeModel): boolean {
|
||||
if (attr.key === 'tags' || (attr.key === '$lookup.attachedTo' && attr.displayProps?.key === 'tags')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const labelStr = typeof attr.label === 'string' ? attr.label : ''
|
||||
if (attr.key === '' && labelStr.endsWith(':Tags')) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag mixins for a card, mirroring CardTagsColored (lines 41-44).
|
||||
* Returns refs of mixin classes that the card has (MIXIN kind, hasMixin(card, m)).
|
||||
*/
|
||||
export function getCardTagMixins (card: Card, hierarchy: Hierarchy): Array<Ref<Class<Doc>>> {
|
||||
const parentClass = hierarchy.getParentClass(card._class)
|
||||
return hierarchy
|
||||
.getDescendants(parentClass)
|
||||
.filter((m) => hierarchy.getClass(m).kind === ClassifierKind.MIXIN && hierarchy.hasMixin(card, m)) as Array<
|
||||
Ref<Class<Doc>>
|
||||
>
|
||||
}
|
||||
|
||||
/**
|
||||
* Format card tags for markdown: same tag set as CardTagsColored, labels joined by ", ".
|
||||
*/
|
||||
export async function formatCardTagsForMarkdown (
|
||||
card: Card,
|
||||
hierarchy: Hierarchy,
|
||||
language: string | undefined
|
||||
): Promise<string> {
|
||||
const mixinRefs = getCardTagMixins(card, hierarchy)
|
||||
const labels: string[] = []
|
||||
for (const ref of mixinRefs) {
|
||||
const mixinClass = hierarchy.getClass(ref)
|
||||
const label = mixinClass?.label
|
||||
if (label !== undefined) {
|
||||
const str =
|
||||
typeof label === 'string' && isIntlString(label) ? await translate(label, {}, language) : String(label)
|
||||
labels.push(str)
|
||||
}
|
||||
}
|
||||
return labels.join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Value formatter for Tag and for the card tags column.
|
||||
* - Card + tags column: return formatCardTagsForMarkdown.
|
||||
* - Card otherwise: return undefined (let formatCardValue handle it).
|
||||
* - Tag-derived doc: format by title / $lookup / _class label.
|
||||
*/
|
||||
export async function formatTagValue (
|
||||
attr: AttributeModel,
|
||||
doc: Doc,
|
||||
hierarchy: Hierarchy,
|
||||
_class: Ref<Class<Doc>>,
|
||||
language: string | undefined
|
||||
): Promise<string | undefined> {
|
||||
const isCardClass = hierarchy.isDerived(_class, cardPlugin.class.Card)
|
||||
if (isCardClass && isTagsColumn(attr)) {
|
||||
return await formatCardTagsForMarkdown(doc as Card, hierarchy, language)
|
||||
}
|
||||
if (isCardClass) {
|
||||
return undefined
|
||||
}
|
||||
if (!hierarchy.isDerived(_class, cardPlugin.class.Tag)) {
|
||||
return undefined
|
||||
}
|
||||
const rec = doc as unknown as Record<string, unknown>
|
||||
const withLookup = rec as Record<string, unknown> & { $lookup?: Record<string, unknown> }
|
||||
|
||||
// Handle _class attribute (MasterTag/Type) - format as translated label
|
||||
const isClassAttr = attr.key === '_class' || attr.key === '$lookup.attachedTo._class' || attr.key.endsWith('._class')
|
||||
if (isClassAttr) {
|
||||
let classValue: unknown = rec._class
|
||||
if (attr.key.startsWith('$lookup.') && attr.key.includes('.')) {
|
||||
const lookupKey = attr.key.replace('$lookup.', '').split('.')[0]
|
||||
const rest = attr.key.replace('$lookup.', '').split('.').slice(1).join('.')
|
||||
const lookupObj = withLookup.$lookup?.[lookupKey] as Record<string, unknown> | undefined
|
||||
if (lookupObj != null) {
|
||||
classValue = rest !== '' ? lookupObj[rest] : lookupObj._class
|
||||
}
|
||||
}
|
||||
if (classValue !== undefined && classValue !== null) {
|
||||
const lookupClass = withLookup.$lookup?._class as Record<string, unknown> | undefined
|
||||
const lookupLabel = lookupClass?.label
|
||||
if (lookupLabel !== undefined) {
|
||||
if (typeof lookupLabel === 'string' && isIntlString(lookupLabel)) {
|
||||
return await translate(lookupLabel, {}, language)
|
||||
}
|
||||
return String(lookupLabel)
|
||||
}
|
||||
if (typeof classValue === 'string') {
|
||||
const cl = hierarchy.getClass(classValue as Ref<Class<Doc>>)
|
||||
const label = cl?.label
|
||||
if (label !== undefined) {
|
||||
return typeof label === 'string' && isIntlString(label) ? await translate(label, {}, language) : String(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tag-derived doc: format by title, lookup, or _class label
|
||||
const title = rec.title
|
||||
if (typeof title === 'string' && title.trim() !== '') {
|
||||
return title.trim()
|
||||
}
|
||||
const lookupTitle = withLookup.$lookup?.title
|
||||
if (typeof lookupTitle === 'string' && lookupTitle.trim() !== '') {
|
||||
return lookupTitle.trim()
|
||||
}
|
||||
const lookupClass = withLookup.$lookup?._class as Record<string, unknown> | undefined
|
||||
const classLabel = lookupClass?.label
|
||||
if (classLabel !== undefined) {
|
||||
if (typeof classLabel === 'string' && isIntlString(classLabel)) {
|
||||
return await translate(classLabel, {}, language)
|
||||
}
|
||||
return String(classLabel)
|
||||
}
|
||||
const classRef = rec._class
|
||||
if (typeof classRef === 'string') {
|
||||
const cl = hierarchy.getClass(classRef as Ref<Class<Doc>>)
|
||||
const label = cl?.label
|
||||
if (label !== undefined) {
|
||||
const str =
|
||||
typeof label === 'string' && isIntlString(label) ? await translate(label, {}, language) : String(label)
|
||||
return str
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -61,6 +61,7 @@ export interface Card extends Doc, IconProps, VersionableDoc {
|
||||
peerId?: string
|
||||
|
||||
readonlySections?: Ref<MasterTag>[]
|
||||
readonlyFields?: string[]
|
||||
}
|
||||
|
||||
export interface CardSpace extends TypedSpace {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { Icon, Label, tooltip } from '@hcengineering/ui'
|
||||
import contact, { SocialIdentity, SocialIdentityProvider } from '@hcengineering/contact'
|
||||
import contact, { SocialIdentity, SocialIdentityProvider, getCurrentEmployee } from '@hcengineering/contact'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
|
||||
export let value: SocialIdentity
|
||||
@@ -23,6 +23,7 @@
|
||||
export let shouldShowAvatar = true
|
||||
|
||||
const client = getClient()
|
||||
let isOwner = false
|
||||
|
||||
onMount(() => {
|
||||
if (socialIdProvider == null) {
|
||||
@@ -31,6 +32,10 @@
|
||||
})
|
||||
|
||||
$: icon = socialIdProvider?.icon ?? contact.icon.Profile
|
||||
$: {
|
||||
const me = getCurrentEmployee()
|
||||
isOwner = me != null && value.attachedTo === me
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if socialIdProvider != null}
|
||||
@@ -46,8 +51,12 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-col flex-gap-0-5">
|
||||
<div>{value.displayValue ?? value.value}</div>
|
||||
{#if shouldShowAvatar}
|
||||
{#if isOwner}
|
||||
<div>{value.displayValue ?? value.value}</div>
|
||||
{#if shouldShowAvatar}
|
||||
<div class="type"><Label label={socialIdProvider.label} /></div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="type"><Label label={socialIdProvider.label} /></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -129,22 +129,35 @@ function resolveDisplayContext (
|
||||
if (attr.key === '' && !isFirstColumn) {
|
||||
const labelStr = typeof attr.label === 'string' ? attr.label : ''
|
||||
const isCustomAttribute = labelStr.startsWith('custom')
|
||||
if (!isCustomAttribute) {
|
||||
return null
|
||||
if (isCustomAttribute) {
|
||||
const customValue = (card as any)[labelStr]
|
||||
let customAttr = hierarchy.findAttribute(docClass, labelStr)
|
||||
if (customAttr === undefined) {
|
||||
const allAttrs = hierarchy.getAllAttributes(docClass)
|
||||
customAttr = allAttrs.get(labelStr)
|
||||
}
|
||||
return {
|
||||
value: customValue,
|
||||
displayDoc: card,
|
||||
displayClass: docClass,
|
||||
attribute: customAttr,
|
||||
lookupKey: customAttr?.name ?? labelStr
|
||||
}
|
||||
}
|
||||
const customValue = (card as any)[labelStr]
|
||||
let customAttr = hierarchy.findAttribute(docClass, labelStr)
|
||||
if (customAttr === undefined) {
|
||||
const allAttrs = hierarchy.getAllAttributes(docClass)
|
||||
customAttr = allAttrs.get(labelStr)
|
||||
}
|
||||
return {
|
||||
value: customValue,
|
||||
displayDoc: card,
|
||||
displayClass: docClass,
|
||||
attribute: customAttr,
|
||||
lookupKey: customAttr?.name ?? labelStr
|
||||
|
||||
// Tags column can be configured with empty key and Tags label (CardTagsColored).
|
||||
// In that case we still want to format it via class-level MarkdownValueFormatter.
|
||||
if (labelStr.endsWith(':Tags')) {
|
||||
return {
|
||||
value: undefined,
|
||||
displayDoc: card,
|
||||
displayClass: docClass,
|
||||
attribute: undefined,
|
||||
lookupKey: 'tags'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let value: any
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
"PasswordRecovery": "Password recovery",
|
||||
"RecoveryLinkSent": "Password recovery link sent to email",
|
||||
"UseWorkspaceInviteSettings": "Use workspace invite settings",
|
||||
"LinkValidHours": "Link valid (hours):",
|
||||
"LinkValidHours": "Invite link expiration (hours):",
|
||||
"EmailMask": "Email mask:",
|
||||
"InviteLimit": "Invite limit:",
|
||||
"InviteLimit": "Maximum uses per invite link:",
|
||||
"GetLink": "Get invite link",
|
||||
"NoLimit": "No limit",
|
||||
"NoLimit": "Unlimited uses:",
|
||||
"AlreadyJoined": "Already joined?",
|
||||
"ConfirmationSent": "A message has been sent to your email containing a link to confirm your address.",
|
||||
"ConfirmationSent2": "Please follow the link to complete your sign up.",
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"@hcengineering/core": "workspace:^0.7.24",
|
||||
"@hcengineering/presentation": "workspace:^0.7.0",
|
||||
"@hcengineering/setting": "workspace:^0.7.0",
|
||||
"@hcengineering/setting-resources": "workspace:^0.7.0",
|
||||
"@hcengineering/theme": "workspace:^0.7.0",
|
||||
"@hcengineering/analytics": "workspace:^0.7.17",
|
||||
"@hcengineering/account-client": "workspace:^0.7.21",
|
||||
|
||||
@@ -15,15 +15,16 @@
|
||||
<script lang="ts">
|
||||
import { AccountRole, getCurrentAccount, hasAccountRole, Timestamp } from '@hcengineering/core'
|
||||
import { copyTextToClipboard, createQuery } from '@hcengineering/presentation'
|
||||
import setting from '@hcengineering/setting'
|
||||
import { Button, EditBox, Grid, Label, Loading, MiniToggle, ticker } from '@hcengineering/ui'
|
||||
import setting, { RoleCapability } from '@hcengineering/setting'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import { AnySvelteComponent, Button, EditBox, Grid, Label, Loading, MiniToggle, ticker } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import login from '../plugin'
|
||||
import { getInviteLink } from '../utils'
|
||||
import InviteWorkspace from './icons/InviteWorkspace.svelte'
|
||||
|
||||
export let role: AccountRole = AccountRole.User
|
||||
export let role: AccountRole | undefined = undefined
|
||||
export let ignoreSettings: boolean = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -43,9 +44,15 @@
|
||||
expHours = set[0].expirationTime
|
||||
emailMask = set[0].emailMask
|
||||
limit = set[0].limit
|
||||
if (role == null) {
|
||||
role = set[0].defaultInviteRole ?? AccountRole.User
|
||||
}
|
||||
} else {
|
||||
expHours = 48
|
||||
limit = -1
|
||||
if (role == null) {
|
||||
role = AccountRole.User
|
||||
}
|
||||
}
|
||||
|
||||
if (limit === -1) noLimit = true
|
||||
@@ -90,7 +97,22 @@
|
||||
let limit: number | undefined = undefined
|
||||
let useDefault: boolean | undefined = true
|
||||
let noLimit: boolean = false
|
||||
const isOwnerOrMaintainer: boolean = hasAccountRole(getCurrentAccount(), AccountRole.Maintainer)
|
||||
const currentAccount = getCurrentAccount()
|
||||
const isOwnerOrMaintainer: boolean = hasAccountRole(currentAccount, AccountRole.Maintainer)
|
||||
let userRoleSelectComponent: AnySvelteComponent | undefined
|
||||
void getResource(setting.component.UserRoleSelect).then((component) => {
|
||||
userRoleSelectComponent = component
|
||||
})
|
||||
let canGenerateInviteLinks = false
|
||||
|
||||
// Use shared HasRoleCapability function resource from setting package instead of setting-resources
|
||||
void getResource(setting.function.HasRoleCapability).then((checkCapability) => {
|
||||
if (checkCapability != null) {
|
||||
void checkCapability(RoleCapability.GenerateInviteLink).then((value: boolean) => {
|
||||
canGenerateInviteLinks = value
|
||||
})
|
||||
}
|
||||
})
|
||||
let defaultValues: InviteParams = {
|
||||
expirationTime: 48,
|
||||
emailMask: '',
|
||||
@@ -99,6 +121,11 @@
|
||||
|
||||
let link: string | undefined
|
||||
let loading = false
|
||||
|
||||
function handleInviteRoleSelected (e: CustomEvent<AccountRole>): void {
|
||||
role = e.detail
|
||||
link = undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="antiPopup popup" class:secure={isSecureContext}>
|
||||
@@ -133,6 +160,13 @@
|
||||
disabled={useDefault || !isOwnerOrMaintainer}
|
||||
/>
|
||||
{/if}
|
||||
{#if userRoleSelectComponent}
|
||||
<svelte:component
|
||||
this={userRoleSelectComponent}
|
||||
selected={role ?? AccountRole.User}
|
||||
on:selected={handleInviteRoleSelected}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</Grid>
|
||||
{/if}
|
||||
@@ -162,8 +196,13 @@
|
||||
label={login.string.GetLink}
|
||||
size={'medium'}
|
||||
kind={'primary'}
|
||||
disabled={!canGenerateInviteLinks}
|
||||
on:click={() => {
|
||||
;((limit !== undefined && limit > 0) || noLimit) && getLink(expHours, emailMask, limit, role)
|
||||
if (!canGenerateInviteLinks) return
|
||||
const effectiveLimit = limit ?? 0
|
||||
if (effectiveLimit > 0 || noLimit) {
|
||||
void getLink(expHours, emailMask, limit, role ?? AccountRole.User)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation, { getFileUrl } from '@hcengineering/presentation'
|
||||
import { convertToHTML } from '@hcengineering/print'
|
||||
import { EmbeddedPDF, Spinner, themeStore } from '@hcengineering/ui'
|
||||
import { EmbeddedHTML, Spinner, themeStore } from '@hcengineering/ui'
|
||||
|
||||
export let value: Ref<Blob>
|
||||
export let name: string
|
||||
@@ -270,7 +270,7 @@
|
||||
<Spinner size="medium" />
|
||||
</div>
|
||||
{:else}
|
||||
<EmbeddedPDF {src} {name} {css} />
|
||||
<EmbeddedHTML {src} {name} {css} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Required": "Povinné",
|
||||
"ParallelExecutionForbidden": "Nedovoluj paralelní spuštění",
|
||||
"StartAutomatically": "Spustit automaticky",
|
||||
"AutomationOnly": "Pouze automatizace",
|
||||
"Error": "Chyba",
|
||||
"Continue": "Pokračovat",
|
||||
"RequestFromUser": "Požádat uživatele",
|
||||
@@ -144,7 +145,14 @@
|
||||
"CancelToDo": "Zrušit akční položku",
|
||||
"Export": "Exportovat",
|
||||
"Import": "Importovat",
|
||||
"CancelProcess": "Zrušit proces"
|
||||
"CancelProcess": "Zrušit proces",
|
||||
"SyncWithField": "Synchronizovat s polem",
|
||||
"ActionType": "Typ",
|
||||
"ApproveAction": "Schválit",
|
||||
"ReviewAction": "Zkontrolovat",
|
||||
"Review": "Zkontrolovat",
|
||||
"LockField": "Zamknout pole",
|
||||
"UnlockField": "Odemknout pole"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Metoda nenalezena: {methodId}",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Required": "Erforderlich",
|
||||
"ParallelExecutionForbidden": "Parallele Ausführung verboten",
|
||||
"StartAutomatically": "Automatisch starten",
|
||||
"AutomationOnly": "Nur Automatisierung",
|
||||
"Error": "Fehler",
|
||||
"Continue": "Fortsetzen",
|
||||
"RequestFromUser": "Anfrage von Benutzer",
|
||||
@@ -144,7 +145,14 @@
|
||||
"CancelToDo": "Action Item abbrechen",
|
||||
"Export": "Exportieren",
|
||||
"Import": "Importieren",
|
||||
"CancelProcess": "Prozess abbrechen"
|
||||
"CancelProcess": "Prozess abbrechen",
|
||||
"SyncWithField": "Synchronisieren mit Feld",
|
||||
"ActionType": "Typ",
|
||||
"ApproveAction": "Genehmigen",
|
||||
"ReviewAction": "Prüfen",
|
||||
"Review": "Prüfen",
|
||||
"LockField": "Feld sperren",
|
||||
"UnlockField": "Feld entsperren"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Methode nicht gefunden: {methodId}",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Required": "Required",
|
||||
"ParallelExecutionForbidden": "Parallel execution forbidden",
|
||||
"StartAutomatically": "Start automatically",
|
||||
"AutomationOnly": "Automation only",
|
||||
"Error": "Error",
|
||||
"Continue": "Continue",
|
||||
"RequestFromUser": "Request from user",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "Cancel Action item",
|
||||
"Export": "Export",
|
||||
"Import": "Import",
|
||||
"CancelProcess": "Cancel process"
|
||||
"CancelProcess": "Cancel process",
|
||||
"SyncWithField": "Sync with field",
|
||||
"ActionType": "Type",
|
||||
"ApproveAction": "Approve",
|
||||
"ReviewAction": "Review",
|
||||
"Review": "Review",
|
||||
"LockField": "Lock field",
|
||||
"UnlockField": "Unlock field"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Method not found: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "Requerido",
|
||||
"ParallelExecutionForbidden": "Ejecución paralela prohibida",
|
||||
"StartAutomatically": "Iniciar automáticamente",
|
||||
"AutomationOnly": "Solo automatización",
|
||||
"Error": "Error",
|
||||
"Continue": "Continuar",
|
||||
"RequestFromUser": "Solicitar desde el usuario",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "Cancelar acción",
|
||||
"Export": "Exportar",
|
||||
"Import": "Importar",
|
||||
"CancelProcess": "Cancelar proceso"
|
||||
"CancelProcess": "Cancelar proceso",
|
||||
"SyncWithField": "Sincronizar con campo",
|
||||
"ActionType": "Tipo",
|
||||
"ApproveAction": "Aprobar",
|
||||
"ReviewAction": "Revisar",
|
||||
"Review": "Revisar",
|
||||
"LockField": "Bloquear campo",
|
||||
"UnlockField": "Desbloquear campo"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Método no encontrado: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "Requis",
|
||||
"ParallelExecutionForbidden": "Exécution parallèle interdite",
|
||||
"StartAutomatically": "Démarrer automatiquement",
|
||||
"AutomationOnly": "Automatisation uniquement",
|
||||
"Error": "Erreur",
|
||||
"Continue": "Continuer",
|
||||
"RequestFromUser": "Demander à partir de l'utilisateur",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "Annuler l'action",
|
||||
"Export": "Exporter",
|
||||
"Import": "Importer",
|
||||
"CancelProcess": "Annuler le processus"
|
||||
"CancelProcess": "Annuler le processus",
|
||||
"SyncWithField": "Synchroniser avec le champ",
|
||||
"ActionType": "Type",
|
||||
"ApproveAction": "Approuver",
|
||||
"ReviewAction": "Revoir",
|
||||
"Review": "Revoir",
|
||||
"LockField": "Verrouiller le champ",
|
||||
"UnlockField": "Déverrouiller le champ"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Méthode introuvable : {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "Obbligatorio",
|
||||
"ParallelExecutionForbidden": "Esecuzione parallela vietata",
|
||||
"StartAutomatically": "Avvia automaticamente",
|
||||
"AutomationOnly": "Solo automazione",
|
||||
"Error": "Errore",
|
||||
"Continue": "Continua",
|
||||
"RequestFromUser": "Richiedi da utente",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "Annulla azione",
|
||||
"Export": "Esporta",
|
||||
"Import": "Importa",
|
||||
"CancelProcess": "Annulla processo"
|
||||
"CancelProcess": "Annulla processo",
|
||||
"SyncWithField": "Sincronizza con campo",
|
||||
"ActionType": "Tipo",
|
||||
"ApproveAction": "Approva",
|
||||
"ReviewAction": "Revisa",
|
||||
"Review": "Revisa",
|
||||
"LockField": "Blocca campo",
|
||||
"UnlockField": "Sblocca campo"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Metodo non trovato: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "必須",
|
||||
"ParallelExecutionForbidden": "並列実行は禁止されています",
|
||||
"StartAutomatically": "自動的に開始",
|
||||
"AutomationOnly": "自動化のみ",
|
||||
"Error": "エラー",
|
||||
"Continue": "続行",
|
||||
"RequestFromUser": "ユーザーからのリクエスト",
|
||||
@@ -148,7 +149,14 @@
|
||||
"CancelToDo": "アクションをキャンセル",
|
||||
"Export": "エクスポート",
|
||||
"Import": "インポート",
|
||||
"CancelProcess": "プロセスをキャンセル"
|
||||
"CancelProcess": "プロセスをキャンセル",
|
||||
"SyncWithField": "フィールドと同期",
|
||||
"ActionType": "タイプ",
|
||||
"ApproveAction": "承認",
|
||||
"ReviewAction": "レビュー",
|
||||
"Review": "レビュー",
|
||||
"LockField": "フィールドをロック",
|
||||
"UnlockField": "フィールドのロックを解除"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "メソッドが見つかりません: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "Obrigatório",
|
||||
"ParallelExecutionForbidden": "Execução Paralela Proibida",
|
||||
"StartAutomatically": "Iniciar automaticamente",
|
||||
"AutomationOnly": "Apenas automação",
|
||||
"Error": "Erro",
|
||||
"Continue": "Continuar",
|
||||
"RequestFromUser": "Solicitar do usuário",
|
||||
@@ -137,7 +138,14 @@
|
||||
"CancelToDo": "Cancelar ação",
|
||||
"Export": "Exportar",
|
||||
"Import": "Importar",
|
||||
"CancelProcess": "Cancelar processo"
|
||||
"CancelProcess": "Cancelar processo",
|
||||
"SyncWithField": "Sincronizar com campo",
|
||||
"ActionType": "Tipo",
|
||||
"ApproveAction": "Aprovar",
|
||||
"ReviewAction": "Revisar",
|
||||
"Review": "Revisar",
|
||||
"LockField": "Bloquear campo",
|
||||
"UnlockField": "Desbloquear campo"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Método não encontrado: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "Obrigatório",
|
||||
"ParallelExecutionForbidden": "Execução Paralela Proibida",
|
||||
"StartAutomatically": "Iniciar automaticamente",
|
||||
"AutomationOnly": "Apenas automação",
|
||||
"Error": "Erro",
|
||||
"Continue": "Continuar",
|
||||
"RequestFromUser": "Solicitar do usuário",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "Cancelar ação",
|
||||
"Export": "Exportar",
|
||||
"Import": "Importar",
|
||||
"CancelProcess": "Cancelar processo"
|
||||
"CancelProcess": "Cancelar processo",
|
||||
"SyncWithField": "Sincronizar com campo",
|
||||
"ActionType": "Tipo",
|
||||
"ApproveAction": "Aprovar",
|
||||
"ReviewAction": "Revisar",
|
||||
"Review": "Revisar",
|
||||
"LockField": "Bloquear campo",
|
||||
"UnlockField": "Desbloquear campo"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Método não encontrado: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "Обязательно",
|
||||
"ParallelExecutionForbidden": "Одновременное выполнение запрещено",
|
||||
"StartAutomatically": "Запускать автоматически",
|
||||
"AutomationOnly": "Только автоматизация",
|
||||
"Error": "Ошибка",
|
||||
"Continue": "Продолжить",
|
||||
"RequestFromUser": "Запросить у пользователя",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "Отменить задачу",
|
||||
"Export": "Экспорт",
|
||||
"Import": "Импорт",
|
||||
"CancelProcess": "Отменить процесс"
|
||||
"CancelProcess": "Отменить процесс",
|
||||
"SyncWithField": "Синхронизировать с полем",
|
||||
"ActionType": "Тип",
|
||||
"ApproveAction": "Утверждение",
|
||||
"ReviewAction": "Рецензирование",
|
||||
"Review": "Рецензировать",
|
||||
"LockField": "Заблокировать поле",
|
||||
"UnlockField": "Разблокировать поле"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Метод не найден: {methodId}",
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"Required": "Gerekli",
|
||||
"ParallelExecutionForbidden": "Paralel yürütme yasak",
|
||||
"StartAutomatically": "Otomatik başlat",
|
||||
"AutomationOnly": "Sadece otomasyon",
|
||||
"Error": "Hata",
|
||||
"Continue": "Devam et",
|
||||
"RequestFromUser": "Kullanıcıdan talep et",
|
||||
@@ -146,7 +147,14 @@
|
||||
"CancelToDo": "Eylem öğesini iptal et",
|
||||
"Export": "Dışa aktar",
|
||||
"Import": "İçe aktar",
|
||||
"CancelProcess": "Süreci iptal et"
|
||||
"CancelProcess": "Süreci iptal et",
|
||||
"SyncWithField": "Alan ile senkronize et",
|
||||
"ActionType": "Tür",
|
||||
"ApproveAction": "Onayla",
|
||||
"ReviewAction": "Gözden geçir",
|
||||
"Review": "Gözden geçir",
|
||||
"LockField": "Alanı kilitle",
|
||||
"UnlockField": "Alanı kilitle"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "Metod bulunamadı: {methodId}",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"Required": "必需",
|
||||
"ParallelExecutionForbidden": "禁止并行执行",
|
||||
"StartAutomatically": "自动启动",
|
||||
"AutomationOnly": "仅限自动化",
|
||||
"Error": "错误",
|
||||
"Continue": "继续",
|
||||
"RequestFromUser": "从用户请求",
|
||||
@@ -149,7 +150,14 @@
|
||||
"CancelToDo": "取消任务",
|
||||
"Export": "导出",
|
||||
"Import": "导入",
|
||||
"CancelProcess": "取消过程"
|
||||
"CancelProcess": "取消过程",
|
||||
"SyncWithField": "字段同步",
|
||||
"ActionType": "类型",
|
||||
"ApproveAction": "批准",
|
||||
"ReviewAction": "审查",
|
||||
"Review": "审查",
|
||||
"LockField": "锁定字段",
|
||||
"UnlockField": "解锁字段"
|
||||
},
|
||||
"error": {
|
||||
"MethodNotFound": "找不到方法:{methodId}",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { Label } from '@hcengineering/ui'
|
||||
import plugin from '../plugin'
|
||||
|
||||
export let value: 'approve' | 'review' | undefined
|
||||
</script>
|
||||
|
||||
<Label label={value === 'review' ? plugin.string.ReviewAction : plugin.string.ApproveAction} />
|
||||
@@ -45,5 +45,9 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button label={process.string.Approve} kind="positive" on:click={(ev) => changeApprovalRequestState(ev, false)} />
|
||||
<Button
|
||||
label={todo.actionType === 'review' ? process.string.Review : process.string.Approve}
|
||||
kind="positive"
|
||||
on:click={(ev) => changeApprovalRequestState(ev, false)}
|
||||
/>
|
||||
<Button label={process.string.Reject} kind="negative" on:click={(ev) => changeApprovalRequestState(ev, true)} />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Execution } from '@hcengineering/process'
|
||||
import plugin from '../plugin'
|
||||
|
||||
export let value: Ref<Execution>
|
||||
|
||||
const client = getClient()
|
||||
|
||||
const query = createQuery()
|
||||
|
||||
let execution: Execution | undefined
|
||||
|
||||
$: query.query(plugin.class.Execution, { _id: value }, (res) => {
|
||||
execution = res[0]
|
||||
})
|
||||
|
||||
$: process = execution !== undefined ? client.getModel().findObject(execution.process) : undefined
|
||||
</script>
|
||||
|
||||
{#if process}
|
||||
{process.name}
|
||||
{/if}
|
||||
@@ -15,7 +15,9 @@
|
||||
<script lang="ts">
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Label } from '@hcengineering/ui'
|
||||
import { State } from '@hcengineering/process'
|
||||
import plugin from '../plugin'
|
||||
|
||||
export let value: Ref<State>
|
||||
|
||||
@@ -26,4 +28,6 @@
|
||||
|
||||
{#if currentState}
|
||||
{currentState.title}
|
||||
{:else if value === null}
|
||||
<span class="opacity-50"><Label label={plugin.string.Started} /></span>
|
||||
{/if}
|
||||
|
||||
@@ -30,7 +30,10 @@
|
||||
let process: Ref<Process> | undefined = value
|
||||
let card: Card | undefined
|
||||
|
||||
const processes = client.getModel().findAllSync(plugin.class.Process, {})
|
||||
const processes = client
|
||||
.getModel()
|
||||
.findAllSync(plugin.class.Process, {})
|
||||
.filter((p) => p.automationOnly !== true)
|
||||
|
||||
const items: ListItem[] = processes.map((p) => ({ label: p.name, _id: p._id }))
|
||||
|
||||
@@ -38,7 +41,8 @@
|
||||
|
||||
async function runProcess (): Promise<void> {
|
||||
if (process === undefined || card === undefined) return
|
||||
await createExecution(card._id, process, card.space)
|
||||
const tx = await createExecution(card._id, process, card.space, client.txFactory)
|
||||
if (tx) await client.tx(tx)
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
const resClasses = getPossibleClasses()
|
||||
|
||||
const res = client.getModel().findAllSync(process.class.Process, {})
|
||||
const processes = res.filter((it) => resClasses.includes(it.masterTag))
|
||||
const processes = res.filter((it) => resClasses.includes(it.masterTag) && it.automationOnly !== true)
|
||||
|
||||
function getCardPossibleClasses (card: Card): Ref<Class<Doc>>[] {
|
||||
const asc = h.getAncestors(card._class)
|
||||
@@ -94,7 +94,8 @@
|
||||
async function runProcess (_id: Ref<Process>): Promise<void> {
|
||||
if (!value) return
|
||||
for (const element of values) {
|
||||
await createExecution(element._id, _id, element.space)
|
||||
const tx = await createExecution(element._id, _id, element.space, client.txFactory)
|
||||
if (tx) await client.tx(tx)
|
||||
}
|
||||
dispatch('close')
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
import contact from '@hcengineering/contact'
|
||||
import core, { AnyAttribute } from '@hcengineering/core'
|
||||
import { getAttributeEditor, getAttributePresenterClass, getClient } from '@hcengineering/presentation'
|
||||
import { ApproveRequest, Process, Step } from '@hcengineering/process'
|
||||
import { AnySvelteComponent } from '@hcengineering/ui'
|
||||
import { ApproveRequest, parseContext, Process, Step } from '@hcengineering/process'
|
||||
import { AnySvelteComponent, Label, Toggle } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import plugin from '../../plugin'
|
||||
import { getContext, getMockAttribute } from '../../utils'
|
||||
@@ -78,6 +78,33 @@
|
||||
}
|
||||
|
||||
getBaseEditor(attribute)
|
||||
|
||||
$: contextValue = typeof params.user === 'string' ? parseContext(params.user) : undefined
|
||||
$: userContext = contextValue?.type === 'attribute'
|
||||
|
||||
$: if (!userContext && params.field) {
|
||||
delete (params as any).field
|
||||
step.params = params
|
||||
dispatch('change', step)
|
||||
}
|
||||
|
||||
function toggleField (e: CustomEvent<boolean>): void {
|
||||
if (e.detail) {
|
||||
if (contextValue?.type === 'attribute') {
|
||||
params.field = contextValue.key
|
||||
}
|
||||
} else {
|
||||
delete (params as any).field
|
||||
}
|
||||
step.params = params
|
||||
dispatch('change', step)
|
||||
}
|
||||
|
||||
function toggleActionType (e: CustomEvent<any>): void {
|
||||
params.actionType = e.detail ? 'review' : 'approve'
|
||||
step.params = params
|
||||
dispatch('change', step)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
@@ -93,6 +120,14 @@
|
||||
on:remove
|
||||
on:change={onChange}
|
||||
/>
|
||||
<div>
|
||||
<Label label={plugin.string.SyncWithField} />
|
||||
</div>
|
||||
<Toggle disabled={!userContext} on={params.field !== undefined} on:change={toggleField} />
|
||||
<div>
|
||||
<Label label={plugin.string.ReviewAction} />
|
||||
</div>
|
||||
<Toggle on={params.actionType === 'review'} on:change={toggleActionType} />
|
||||
</div>
|
||||
<ParamsEditor _class={plugin.class.ApproveRequest} {process} {keys} {params} on:change={changeParams} />
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import presentation, { getClient } from '@hcengineering/presentation'
|
||||
import { Process, Transition, Trigger } from '@hcengineering/process'
|
||||
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Process, State, Transition, Trigger } from '@hcengineering/process'
|
||||
import { clearSettingsStore } from '@hcengineering/setting-resources'
|
||||
import {
|
||||
ButtonIcon,
|
||||
Component,
|
||||
Dropdown,
|
||||
DropdownIntlItem,
|
||||
DropdownLabelsIntl,
|
||||
IconDelete,
|
||||
Label,
|
||||
ListItem,
|
||||
Modal
|
||||
} from '@hcengineering/ui'
|
||||
import plugin from '../../plugin'
|
||||
@@ -23,6 +25,14 @@
|
||||
|
||||
const client = getClient()
|
||||
|
||||
let states: State[] = []
|
||||
const statesQuery = createQuery()
|
||||
|
||||
$: process &&
|
||||
statesQuery.query(plugin.class.State, { process: process?._id }, (res) => {
|
||||
states = res
|
||||
})
|
||||
|
||||
async function save (): Promise<void> {
|
||||
await client.update(transition, { triggerParams: params, trigger: selectedTrigger })
|
||||
clearSettingsStore()
|
||||
@@ -45,6 +55,32 @@
|
||||
const triggers = client.getModel().findAllSync(plugin.class.Trigger, { init: transition.from === null })
|
||||
|
||||
const triggersItems: DropdownIntlItem[] = triggers.map((p) => ({ label: p.label, id: p._id, icon: p.icon }))
|
||||
|
||||
let statesItems: ListItem[] = []
|
||||
$: statesItems = states.map((s) => ({ label: s.title, _id: s._id }))
|
||||
|
||||
let fromState: ListItem | undefined
|
||||
$: fromState = statesItems.find((s) => s._id === (transition?.from as string))
|
||||
|
||||
let toState: ListItem | undefined
|
||||
$: toState = statesItems.find((s) => s._id === (transition?.to as string))
|
||||
|
||||
$: withoutFrom =
|
||||
process &&
|
||||
client.getModel().findAllSync(plugin.class.Transition, { from: null, process: process._id })[0] === undefined
|
||||
|
||||
async function updateFrom (e: CustomEvent<ListItem>): Promise<void> {
|
||||
if (transition === undefined) return
|
||||
const from = (e.detail?._id as Ref<State>) ?? null
|
||||
await client.update(transition, { from })
|
||||
}
|
||||
|
||||
async function updateTo (e: CustomEvent<ListItem>): Promise<void> {
|
||||
if (transition === undefined) return
|
||||
const to = e.detail?._id as Ref<State>
|
||||
if (to === undefined) return
|
||||
await client.update(transition, { to })
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
@@ -69,6 +105,30 @@
|
||||
</div>
|
||||
{#if trigger !== undefined}
|
||||
<div class="editor-grid">
|
||||
<Label label={plugin.string.From} />
|
||||
{#if !withoutFrom || transition.from === null}
|
||||
<Dropdown
|
||||
items={statesItems}
|
||||
selected={fromState}
|
||||
placeholder={plugin.string.From}
|
||||
justify={'left'}
|
||||
width={'100%'}
|
||||
kind={'no-border'}
|
||||
on:selected={updateFrom}
|
||||
/>
|
||||
{:else}
|
||||
<div>⦳</div>
|
||||
{/if}
|
||||
<Label label={plugin.string.To} />
|
||||
<Dropdown
|
||||
items={statesItems}
|
||||
selected={toState}
|
||||
placeholder={plugin.string.To}
|
||||
justify={'left'}
|
||||
width={'100%'}
|
||||
kind={'no-border'}
|
||||
on:selected={updateTo}
|
||||
/>
|
||||
<Label label={plugin.string.Trigger} />
|
||||
<DropdownLabelsIntl
|
||||
items={triggersItems}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 cardPlugin, { Card, MasterTag, Tag } from '@hcengineering/card'
|
||||
import core, { AnyAttribute, Class, Ref } from '@hcengineering/core'
|
||||
import { Process, Step } from '@hcengineering/process'
|
||||
import { Button, eventToHTMLElement, IconClose, Label, SelectPopup, showPopup, tooltip } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import TagSelector from './TagSelector.svelte'
|
||||
import presentation, { getClient } from '@hcengineering/presentation'
|
||||
|
||||
export let process: Process
|
||||
export let step: Step<Card>
|
||||
|
||||
const params = step.params
|
||||
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function getKeys (_class: Ref<Class<MasterTag>>): AnyAttribute[] {
|
||||
const ignoreKeys = ['_class', 'content', 'parent', 'attachments', 'todos']
|
||||
const attributes = hierarchy.getAllAttributes(_class, core.class.Doc)
|
||||
const res: AnyAttribute[] = []
|
||||
for (const [key, attr] of attributes) {
|
||||
if (attr.hidden === true) continue
|
||||
if (ignoreKeys.includes(key)) continue
|
||||
res.push(attr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
function change (value: string[]): void {
|
||||
params.value = value
|
||||
step.params = params
|
||||
dispatch('change', step)
|
||||
}
|
||||
|
||||
let keys: string[] = params.value ?? []
|
||||
|
||||
$: allAttrs = getKeys(process.masterTag)
|
||||
$: possibleAttrs = allAttrs.filter((attr) => !keys.includes(attr.name))
|
||||
|
||||
function addKey (key: string): void {
|
||||
keys = [...keys, key]
|
||||
change(keys)
|
||||
}
|
||||
|
||||
function onAdd (e: MouseEvent): void {
|
||||
showPopup(
|
||||
SelectPopup,
|
||||
{
|
||||
value: possibleAttrs.map((p) => {
|
||||
return { id: p.name, label: p.label }
|
||||
})
|
||||
},
|
||||
eventToHTMLElement(e),
|
||||
(res) => {
|
||||
if (res != null) {
|
||||
addKey(res)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function remove (key: string): void {
|
||||
keys = keys.filter((k) => k !== key)
|
||||
change(keys)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid">
|
||||
{#each keys as key (key)}
|
||||
{@const attribute = allAttrs.find((attr) => attr.name === key)}
|
||||
{#if attribute}
|
||||
<span
|
||||
class="labelOnPanel"
|
||||
use:tooltip={{
|
||||
props: { label: attribute.label }
|
||||
}}
|
||||
>
|
||||
<Label label={attribute.label} />
|
||||
</span>
|
||||
<div class="button flex-row-center">
|
||||
<Button icon={IconClose} kind="ghost" on:click={() => { remove(key) }} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if possibleAttrs.length > 0}
|
||||
<div class="flex-center mt-4">
|
||||
<Button label={presentation.string.Add} width={'100%'} kind={'link-bordered'} size={'large'} on:click={onAdd} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1.5fr;
|
||||
grid-auto-rows: minmax(2rem, max-content);
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
row-gap: 0.5rem;
|
||||
column-gap: 1rem;
|
||||
margin: 0.25rem 2rem 0;
|
||||
width: calc(100% - 4rem);
|
||||
height: min-content;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { Label } from '@hcengineering/ui'
|
||||
import plugin from '../../plugin'
|
||||
import { Process } from '@hcengineering/process'
|
||||
|
||||
export let process: Process
|
||||
export let params: Record<string, any>
|
||||
export let unlock: boolean = false
|
||||
|
||||
const client = getClient()
|
||||
const h = client.getHierarchy()
|
||||
|
||||
const keys = params.value ?? []
|
||||
|
||||
const attrs = keys.map((key: string) => h.getAttribute(process.masterTag, key))
|
||||
</script>
|
||||
|
||||
<Label label={unlock ? plugin.string.UnlockField : plugin.string.LockField} />:
|
||||
{#each attrs as attr}
|
||||
<Label label={attr.label} />
|
||||
{/each}
|
||||
@@ -35,6 +35,12 @@
|
||||
await client.update(value, { autoStart: e.detail })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutomationOnly (e: CustomEvent<boolean>): Promise<void> {
|
||||
if (value !== undefined) {
|
||||
await client.update(value, { automationOnly: e.detail })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card
|
||||
@@ -54,5 +60,16 @@
|
||||
label={process.string.ParallelExecutionForbidden}
|
||||
/>
|
||||
</div>
|
||||
<ToggleWithLabel on={value.autoStart ?? false} on:change={saveAutoStart} label={process.string.StartAutomatically} />
|
||||
<div class="mb-2">
|
||||
<ToggleWithLabel
|
||||
on={value.autoStart ?? false}
|
||||
on:change={saveAutoStart}
|
||||
label={process.string.StartAutomatically}
|
||||
/>
|
||||
</div>
|
||||
<ToggleWithLabel
|
||||
on={value.automationOnly ?? false}
|
||||
on:change={saveAutomationOnly}
|
||||
label={process.string.AutomationOnly}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Process, ProcessToDo, Step, UserResult } from '@hcengineering/process'
|
||||
import { parseContext, Process, ProcessToDo, Step, UserResult } from '@hcengineering/process'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import plugin from '../../plugin'
|
||||
import ParamsEditor from './ParamsEditor.svelte'
|
||||
@@ -43,6 +43,27 @@
|
||||
}
|
||||
|
||||
const keys = ['title', 'user', 'dueDate']
|
||||
|
||||
$: contextValue = typeof params.user === 'string' ? parseContext(params.user) : undefined
|
||||
$: userContext = contextValue?.type === 'attribute'
|
||||
|
||||
$: if (!userContext && params.field) {
|
||||
delete (params as any).field
|
||||
step.params = params
|
||||
dispatch('change', step)
|
||||
}
|
||||
|
||||
function toggleField (e: CustomEvent<boolean>): void {
|
||||
if (e.detail) {
|
||||
if (contextValue?.type === 'attribute') {
|
||||
params.field = contextValue.key
|
||||
}
|
||||
} else {
|
||||
delete (params as any).field
|
||||
}
|
||||
step.params = params
|
||||
dispatch('change', step)
|
||||
}
|
||||
</script>
|
||||
|
||||
<ParamsEditor _class={plugin.class.ProcessToDo} {process} {keys} {params} on:change={changeParams} />
|
||||
@@ -60,6 +81,12 @@
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<Label label={plugin.string.SyncWithField} />
|
||||
</div>
|
||||
<Toggle disabled={!userContext} on={params.field !== undefined} on:change={toggleField} />
|
||||
</div>
|
||||
<div class="divider" />
|
||||
<ResultsEditor {process} result={step.results} on:change={changeResults} />
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { Process } from '@hcengineering/process'
|
||||
import LockFieldPresenter from './LockFieldPresenter.svelte'
|
||||
|
||||
export let process: Process
|
||||
export let params: Record<string, any>
|
||||
</script>
|
||||
|
||||
<LockFieldPresenter {process} {params} unlock />
|
||||
@@ -1,21 +1,38 @@
|
||||
import { type MasterTag } from '@hcengineering/card'
|
||||
import core, { type Doc, type Ref, type Class, generateId } from '@hcengineering/core'
|
||||
import card from '@hcengineering/card'
|
||||
import core, {
|
||||
type Doc,
|
||||
type Ref,
|
||||
type Class,
|
||||
generateId,
|
||||
type EnumOf,
|
||||
type RefTo,
|
||||
type ArrOf,
|
||||
type ModelDb,
|
||||
type Hierarchy,
|
||||
type Type
|
||||
} from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import process from './plugin'
|
||||
import { type Process, type Transition } from '@hcengineering/process'
|
||||
import processPlugin from './plugin'
|
||||
|
||||
export function exportProcesses (_id: Ref<MasterTag>): {
|
||||
docs: Doc[]
|
||||
required: Array<Ref<Class<Doc>>>
|
||||
} {
|
||||
const docs: Doc[] = []
|
||||
const required: Array<Ref<Class<Doc>>> = []
|
||||
const client = getClient()
|
||||
const m = client.getModel()
|
||||
const processes = m.findAllSync(process.class.Process, { masterTag: _id })
|
||||
const processes = m.findAllSync(processPlugin.class.Process, { masterTag: _id })
|
||||
for (const proc of processes) {
|
||||
const res = exportProcess(proc)
|
||||
docs.push(...res.docs)
|
||||
for (const req of res.required) {
|
||||
if (!required.includes(req)) required.push(req)
|
||||
}
|
||||
}
|
||||
return { docs, required: [] }
|
||||
return { docs, required }
|
||||
}
|
||||
|
||||
export function exportProcess (proc: Doc): {
|
||||
@@ -23,13 +40,93 @@ export function exportProcess (proc: Doc): {
|
||||
required: Array<Ref<Class<Doc>>>
|
||||
} {
|
||||
const docs: Doc[] = [proc]
|
||||
const required: Array<Ref<Class<Doc>>> = []
|
||||
const client = getClient()
|
||||
const m = client.getModel()
|
||||
const h = client.getHierarchy()
|
||||
|
||||
docs.push(...m.findAllSync(process.class.State, { process: proc._id as any }))
|
||||
docs.push(...m.findAllSync(process.class.Transition, { process: proc._id as any }))
|
||||
docs.push(...m.findAllSync(processPlugin.class.State, { process: proc._id as any }))
|
||||
const transitions = m.findAllSync(processPlugin.class.Transition, { process: proc._id as any }) as Transition[]
|
||||
docs.push(...transitions)
|
||||
|
||||
return { docs, required: [] }
|
||||
const processDoc = proc as Process
|
||||
|
||||
for (const tr of transitions) {
|
||||
processParams(tr.triggerParams, processDoc.masterTag, docs, required, m, h)
|
||||
for (const action of tr.actions) {
|
||||
processParams(action.params, processDoc.masterTag, docs, required, m, h)
|
||||
if (action.results !== undefined) {
|
||||
for (const res of action.results) {
|
||||
processType(res.type, docs, required, m, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processDoc.context !== undefined) {
|
||||
for (const key in processDoc.context) {
|
||||
const ctx = processDoc.context[key as any]
|
||||
if (ctx.type !== undefined) {
|
||||
processType(ctx.type, docs, required, m, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processDoc.resultType !== undefined) {
|
||||
processType(processDoc.resultType, docs, required, m, h)
|
||||
}
|
||||
|
||||
return { docs, required }
|
||||
}
|
||||
|
||||
function processType (type: Type<any>, docs: Doc[], required: Array<Ref<Class<Doc>>>, m: ModelDb, h: Hierarchy): void {
|
||||
if (type._class === core.class.EnumOf) {
|
||||
const enumRef = (type as EnumOf).of
|
||||
const enumDoc = m.findObject(enumRef)
|
||||
if (enumDoc !== undefined && !docs.some((d) => d._id === enumDoc._id)) {
|
||||
docs.push(enumDoc)
|
||||
}
|
||||
}
|
||||
if (type._class === core.class.RefTo) {
|
||||
const to = (type as RefTo<Doc>).to
|
||||
if (h.isDerived(to, card.class.Card) && !required.includes(to)) {
|
||||
required.push(to)
|
||||
}
|
||||
}
|
||||
if (type._class === core.class.ArrOf) {
|
||||
const of = (type as ArrOf<Doc>).of
|
||||
processType(of, docs, required, m, h)
|
||||
}
|
||||
}
|
||||
|
||||
function processParams (
|
||||
params: Record<string, any> | undefined,
|
||||
masterTag: Ref<MasterTag>,
|
||||
docs: Doc[],
|
||||
required: Array<Ref<Class<Doc>>>,
|
||||
m: ModelDb,
|
||||
h: Hierarchy
|
||||
): void {
|
||||
if (params === undefined) return
|
||||
for (const key in params) {
|
||||
if (key.startsWith('$')) {
|
||||
const val = params[key]
|
||||
if (Array.isArray(val)) {
|
||||
for (const item of val) {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
processParams(item, masterTag, docs, required, m, h)
|
||||
}
|
||||
}
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
processParams(val, masterTag, docs, required, m, h)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const attr = h.findAttribute(masterTag, key)
|
||||
if (attr !== undefined) {
|
||||
processType(attr.type, docs, required, m, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function importProcess (masterTag: Ref<MasterTag>, json: string): Promise<void> {
|
||||
|
||||
@@ -89,6 +89,12 @@ import ReplaceEditor from './components/transformEditors/ReplaceEditor.svelte'
|
||||
import SplitEditor from './components/transformEditors/SplitEditor.svelte'
|
||||
import NumberPresenter from './components/transformPresenters/NumberPresenter.svelte'
|
||||
import RolePresenter from './components/transformPresenters/RolePresenter.svelte'
|
||||
import ExecutionRefPresenter from './components/ExecutionRefPresenter.svelte'
|
||||
import ActionTypePresenter from './components/ActionTypePresenter.svelte'
|
||||
import LockFieldEditor from './components/settings/LockFieldEditor.svelte'
|
||||
import LockFieldPresenter from './components/settings/LockFieldPresenter.svelte'
|
||||
import UnLockFieldPresenter from './components/settings/UnLockFieldPresenter.svelte'
|
||||
|
||||
import { exportProcesses, importProcess } from './exporter'
|
||||
import { ProcessMiddleware } from './middleware'
|
||||
import {
|
||||
@@ -175,7 +181,12 @@ export default async (): Promise<Resources> => ({
|
||||
UnLockSectionPresenter,
|
||||
CancelToDoEditor,
|
||||
CancelSubProcessEditor,
|
||||
ToDoValuePresenter
|
||||
ToDoValuePresenter,
|
||||
ExecutionRefPresenter,
|
||||
ActionTypePresenter,
|
||||
LockFieldEditor,
|
||||
LockFieldPresenter,
|
||||
UnLockFieldPresenter
|
||||
},
|
||||
criteriaEditor: {
|
||||
BaseCriteria,
|
||||
|
||||
@@ -13,14 +13,17 @@
|
||||
|
||||
import cardPlugin, { type Card } from '@hcengineering/card'
|
||||
import core, {
|
||||
generateId,
|
||||
getCurrentAccount,
|
||||
SortingOrder,
|
||||
TxOperations,
|
||||
TxProcessor,
|
||||
type Client,
|
||||
type Doc,
|
||||
type Tx,
|
||||
type TxApplyIf,
|
||||
type TxCreateDoc,
|
||||
type TxCUD,
|
||||
type TxMixin,
|
||||
type TxResult,
|
||||
type TxUpdateDoc
|
||||
@@ -50,27 +53,47 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
return new ProcessMiddleware(client, next)
|
||||
}
|
||||
|
||||
private readonly txFactory = new TxOperations(this.client, getCurrentAccount().primarySocialId).txFactory
|
||||
|
||||
async tx (tx: Tx): Promise<TxResult> {
|
||||
await this.handleTx(tx)
|
||||
const preTx: Array<TxCUD<Doc>> = []
|
||||
const postTx: Array<TxCUD<Doc>> = []
|
||||
await this.handleTx(preTx, postTx, tx)
|
||||
|
||||
if (preTx.length > 0 || postTx.length > 0) {
|
||||
if (TxProcessor.isExtendsCUD(tx._class)) {
|
||||
const applyIf = this.txFactory.createTxApplyIf(
|
||||
core.space.Tx,
|
||||
generateId(),
|
||||
[],
|
||||
[],
|
||||
[...preTx, tx as TxCUD<Doc>, ...postTx],
|
||||
'process',
|
||||
true
|
||||
)
|
||||
return await this.provideTx(applyIf)
|
||||
}
|
||||
}
|
||||
|
||||
return await this.provideTx(tx)
|
||||
}
|
||||
|
||||
private async handleTx (...txes: Tx[]): Promise<void> {
|
||||
private async handleTx (preTx: Array<TxCUD<Doc>>, postTx: Array<TxCUD<Doc>>, ...txes: Tx[]): Promise<void> {
|
||||
for (const etx of txes) {
|
||||
if (etx._class === core.class.TxApplyIf) {
|
||||
const applyIf = etx as TxApplyIf
|
||||
await this.handleTx(...applyIf.txes)
|
||||
await this.handleTx(preTx, postTx, ...applyIf.txes)
|
||||
}
|
||||
|
||||
await this.handleCardCreate(etx)
|
||||
await this.handleCardUpdate(etx)
|
||||
await this.handleTagAdd(etx)
|
||||
await this.handleToDoDone(etx)
|
||||
await this.handleApproveRequest(etx)
|
||||
await this.handleCardCreate(postTx, etx)
|
||||
await this.handleCardUpdate(preTx, etx)
|
||||
await this.handleTagAdd(postTx, etx)
|
||||
await this.handleToDoDone(preTx, etx)
|
||||
await this.handleApproveRequest(preTx, etx)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCardUpdate (etx: Tx): Promise<void> {
|
||||
private async handleCardUpdate (preTx: Array<TxCUD<Doc>>, etx: Tx): Promise<void> {
|
||||
if (etx._class === core.class.TxUpdateDoc || etx._class === core.class.TxMixin) {
|
||||
const updateTx = etx as TxUpdateDoc<Card> | TxMixin<Card, Card>
|
||||
const hierarchy = this.client.getHierarchy()
|
||||
@@ -97,21 +120,26 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
},
|
||||
{ sort: { rank: SortingOrder.Ascending } }
|
||||
)
|
||||
const transition = await pickTransition(this.client, execution, transitions, {
|
||||
const inputContext = {
|
||||
...execution.context,
|
||||
card: updated,
|
||||
operations: isUpdateTx(updateTx) ? updateTx.operations : updateTx.attributes
|
||||
})
|
||||
}
|
||||
const transition = await pickTransition(this.client, execution, transitions, inputContext)
|
||||
if (transition === undefined) return
|
||||
const context = await getNextStateUserInput(execution, transition, execution.context)
|
||||
const txop = new TxOperations(this.client, getCurrentAccount().primarySocialId)
|
||||
await txop.update(execution, {
|
||||
context
|
||||
})
|
||||
const result = await getNextStateUserInput(execution, transition, execution.context, inputContext)
|
||||
if (result?.changed === true) {
|
||||
preTx.push(
|
||||
this.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
|
||||
context: result.context
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCardCreate (etx: Tx): Promise<void> {
|
||||
private async handleCardCreate (postTx: Array<TxCUD<Doc>>, etx: Tx): Promise<void> {
|
||||
if (etx._class === core.class.TxCreateDoc) {
|
||||
const createTx = etx as TxCreateDoc<Card>
|
||||
const doc = TxProcessor.createDoc2Doc(createTx)
|
||||
@@ -130,12 +158,13 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
autoStart: true
|
||||
})
|
||||
for (const proc of processes) {
|
||||
await createExecution(createTx.objectId, proc._id, createTx.objectSpace)
|
||||
const res = await createExecution(createTx.objectId, proc._id, createTx.objectSpace, this.txFactory)
|
||||
if (res !== undefined) postTx.push(res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleTagAdd (tx: Tx): Promise<void> {
|
||||
private async handleTagAdd (postTx: Array<TxCUD<Doc>>, tx: Tx): Promise<void> {
|
||||
if (tx._class !== core.class.TxMixin) return
|
||||
const mixinTx = tx as TxMixin<Card, Card>
|
||||
const hierarchy = this.client.getHierarchy()
|
||||
@@ -146,11 +175,12 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
.getModel()
|
||||
.findAllSync(process.class.Process, { masterTag: mixinTx.mixin, autoStart: true })
|
||||
for (const proc of processes) {
|
||||
await createExecution(mixinTx.objectId, proc._id, mixinTx.objectSpace)
|
||||
const res = await createExecution(mixinTx.objectId, proc._id, mixinTx.objectSpace, this.txFactory)
|
||||
if (res !== undefined) postTx.push(res)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApproveRequest (etx: Tx): Promise<void> {
|
||||
private async handleApproveRequest (preTx: Array<TxCUD<Doc>>, etx: Tx): Promise<void> {
|
||||
if (etx._class === core.class.TxUpdateDoc) {
|
||||
const cud = etx as TxUpdateDoc<ApproveRequest>
|
||||
if (cud.objectClass !== process.class.ApproveRequest) return
|
||||
@@ -163,7 +193,6 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
_id: approveRequest.execution
|
||||
})
|
||||
if (execution === undefined) return
|
||||
const txop = new TxOperations(this.client, getCurrentAccount().primarySocialId)
|
||||
const transitions = this.client.getModel().findAllSync(
|
||||
process.class.Transition,
|
||||
{
|
||||
@@ -176,18 +205,24 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
{ sort: { rank: SortingOrder.Ascending } }
|
||||
)
|
||||
const updatedApproveRequest = TxProcessor.updateDoc2Doc(approveRequest, cud)
|
||||
const transition = await pickTransition(this.client, execution, transitions, {
|
||||
const inputContext = {
|
||||
...execution.context,
|
||||
todo: updatedApproveRequest
|
||||
})
|
||||
}
|
||||
const transition = await pickTransition(this.client, execution, transitions, inputContext)
|
||||
if (transition === undefined) return
|
||||
const context = await getNextStateUserInput(execution, transition, execution.context)
|
||||
await txop.update(execution, {
|
||||
context
|
||||
})
|
||||
const result = await getNextStateUserInput(execution, transition, execution.context, inputContext)
|
||||
if (result?.changed === true) {
|
||||
preTx.push(
|
||||
this.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
|
||||
context: result.context
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleToDoDone (etx: Tx): Promise<void> {
|
||||
private async handleToDoDone (preTx: Array<TxCUD<Doc>>, etx: Tx): Promise<void> {
|
||||
if (etx._class === core.class.TxUpdateDoc) {
|
||||
const cud = etx as TxUpdateDoc<ProcessToDo>
|
||||
if (cud.objectClass !== process.class.ProcessToDo) return
|
||||
@@ -200,22 +235,42 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
|
||||
_id: todo.execution
|
||||
})
|
||||
if (execution === undefined) return
|
||||
const txop = new TxOperations(this.client, getCurrentAccount().primarySocialId)
|
||||
await requestResult(txop, execution, todo.results, execution.context)
|
||||
|
||||
const context = await requestResult(execution, todo.results, execution.context)
|
||||
|
||||
const transitions = this.client.getModel().findAllSync(process.class.Transition, {
|
||||
process: execution.process,
|
||||
from: execution.currentState,
|
||||
trigger: process.trigger.OnToDoClose
|
||||
})
|
||||
const transition = await pickTransition(this.client, execution, transitions, {
|
||||
const inputContext = {
|
||||
...(context ?? execution.context),
|
||||
todo
|
||||
})
|
||||
if (transition === undefined) return
|
||||
const context = await getNextStateUserInput(execution, transition, execution.context)
|
||||
if (context !== undefined) {
|
||||
await txop.update(execution, {
|
||||
context
|
||||
})
|
||||
}
|
||||
const transition = await pickTransition(this.client, execution, transitions, inputContext)
|
||||
if (transition === undefined) {
|
||||
if (context !== undefined) {
|
||||
preTx.push(
|
||||
this.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
|
||||
context
|
||||
})
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
const finalResult = await getNextStateUserInput(execution, transition, context ?? execution.context, inputContext)
|
||||
if (finalResult?.changed === true) {
|
||||
preTx.push(
|
||||
this.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
|
||||
context: finalResult.context
|
||||
})
|
||||
)
|
||||
} else if (context !== undefined) {
|
||||
preTx.push(
|
||||
this.txFactory.createTxUpdateDoc(execution._class, execution.space, execution._id, {
|
||||
context
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,13 @@ export default mergeIds(processId, process, {
|
||||
LockSectionEditor: '' as AnyComponent,
|
||||
LockSectionPresenter: '' as AnyComponent,
|
||||
UnLockSectionPresenter: '' as AnyComponent,
|
||||
LockFieldEditor: '' as AnyComponent,
|
||||
LockFieldPresenter: '' as AnyComponent,
|
||||
UnLockFieldPresenter: '' as AnyComponent,
|
||||
CancelToDoEditor: '' as AnyComponent,
|
||||
ToDoValuePresenter: '' as AnyComponent
|
||||
ToDoValuePresenter: '' as AnyComponent,
|
||||
ActionTypePresenter: '' as AnyComponent,
|
||||
ExecutionRefPresenter: '' as AnyComponent
|
||||
},
|
||||
criteriaEditor: {
|
||||
BaseCriteria: '' as AnyComponent,
|
||||
@@ -181,6 +186,7 @@ export default mergeIds(processId, process, {
|
||||
Required: '' as IntlString,
|
||||
ParallelExecutionForbidden: '' as IntlString,
|
||||
StartAutomatically: '' as IntlString,
|
||||
AutomationOnly: '' as IntlString,
|
||||
Continue: '' as IntlString,
|
||||
RequestFromUser: '' as IntlString,
|
||||
EnterValue: '' as IntlString,
|
||||
@@ -252,6 +258,8 @@ export default mergeIds(processId, process, {
|
||||
LockSection: '' as IntlString,
|
||||
UnlockCard: '' as IntlString,
|
||||
UnlockSection: '' as IntlString,
|
||||
LockField: '' as IntlString,
|
||||
UnlockField: '' as IntlString,
|
||||
Export: '' as IntlString,
|
||||
Import: '' as IntlString
|
||||
},
|
||||
|
||||
@@ -20,12 +20,16 @@ import core, {
|
||||
type Client,
|
||||
type Doc,
|
||||
type DocumentQuery,
|
||||
type DocumentUpdate,
|
||||
findProperty,
|
||||
generateId,
|
||||
matchQuery,
|
||||
type Ref,
|
||||
type RefTo,
|
||||
type Space,
|
||||
type TxOperations,
|
||||
type TxCUD,
|
||||
type TxFactory,
|
||||
TxProcessor,
|
||||
type Type
|
||||
} from '@hcengineering/core'
|
||||
import { getResource, type IntlString, PlatformError, Severity, Status } from '@hcengineering/platform'
|
||||
@@ -207,9 +211,9 @@ export function getContext (
|
||||
}
|
||||
}
|
||||
const allRelations = client.getModel().findAllSync(core.class.Association, {})
|
||||
const descendants = new Set(client.getHierarchy().getDescendants(process.masterTag))
|
||||
const ancestors = new Set(client.getHierarchy().getAncestors(process.masterTag))
|
||||
|
||||
const relationsA = allRelations.filter((it) => descendants.has(it.classA))
|
||||
const relationsA = allRelations.filter((it) => ancestors.has(it.classA))
|
||||
for (const rel of relationsA) {
|
||||
if (['object', 'array'].includes(category) && client.getHierarchy().isDerived(rel.classB, target)) {
|
||||
relations[rel.nameB] = {
|
||||
@@ -231,7 +235,7 @@ export function getContext (
|
||||
}
|
||||
}
|
||||
|
||||
const relationsB = allRelations.filter((it) => descendants.has(it.classB))
|
||||
const relationsB = allRelations.filter((it) => ancestors.has(it.classB))
|
||||
for (const rel of relationsB) {
|
||||
if (['object', 'array'].includes(category) && client.getHierarchy().isDerived(rel.classA, target)) {
|
||||
relations[rel.nameA] = {
|
||||
@@ -420,13 +424,13 @@ export async function continueExecution (value: Execution): Promise<void> {
|
||||
let context = value.context
|
||||
const transition = value.error[0].transition
|
||||
if (transition == null) {
|
||||
const res = await newExecutionUserInput(value.process, value.space, context)
|
||||
context = res ?? context
|
||||
const res = await newExecutionUserInput(value.process, value.space, value)
|
||||
context = res?.context ?? context
|
||||
} else {
|
||||
const _transition = client.getModel().findObject(transition)
|
||||
if (_transition === undefined) return
|
||||
const res = await getNextStateUserInput(value, _transition, context)
|
||||
context = res ?? context
|
||||
context = res?.context ?? context
|
||||
}
|
||||
await client.update(value, { status: ExecutionStatus.Active, context })
|
||||
}
|
||||
@@ -435,17 +439,57 @@ export async function requestUserInput (
|
||||
processId: Ref<Process>,
|
||||
space: Ref<Space>,
|
||||
target: Transition,
|
||||
userContext: ExecutionContext
|
||||
): Promise<ExecutionContext | undefined> {
|
||||
execution: Execution,
|
||||
userContext: ExecutionContext,
|
||||
inputContext: Record<string, any> = {}
|
||||
): Promise<{ context: ExecutionContext, state: Ref<State>, changed: boolean }> {
|
||||
const client = getClient()
|
||||
let changed = false
|
||||
const tr = await getTransitionUserInput(processId, space, target, userContext)
|
||||
if (tr !== undefined) {
|
||||
userContext = { ...userContext, ...tr }
|
||||
changed = true
|
||||
}
|
||||
const sub = await getSubProcessesUserInput(space, target, userContext)
|
||||
const sub = await getSubProcessesUserInput(execution, space, target, userContext)
|
||||
if (sub !== undefined) {
|
||||
userContext = { ...userContext, ...sub }
|
||||
changed = true
|
||||
}
|
||||
return sub !== undefined || tr !== undefined ? userContext : undefined
|
||||
|
||||
// Follow auto transitions
|
||||
const nextAutoTransitions = client
|
||||
.getModel()
|
||||
.findAllSync(process.class.Transition, {
|
||||
process: processId,
|
||||
from: target.to
|
||||
})
|
||||
.filter((t) => client.getModel().findObject(t.trigger)?.auto === true)
|
||||
|
||||
if (nextAutoTransitions.length > 0) {
|
||||
const newExecution = {
|
||||
...execution,
|
||||
context: userContext,
|
||||
currentState: target.to
|
||||
}
|
||||
const nextTransition = await pickTransition(client, newExecution, nextAutoTransitions, inputContext)
|
||||
if (nextTransition !== undefined) {
|
||||
const recursive = await requestUserInput(
|
||||
processId,
|
||||
space,
|
||||
nextTransition,
|
||||
newExecution,
|
||||
userContext,
|
||||
inputContext
|
||||
)
|
||||
return {
|
||||
context: recursive.context,
|
||||
state: recursive.state,
|
||||
changed: changed || recursive.changed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { context: userContext, state: target.to, changed }
|
||||
}
|
||||
|
||||
export async function getTransitionUserInput (
|
||||
@@ -500,6 +544,7 @@ function getEmptyContext (): ExecutionContext {
|
||||
}
|
||||
|
||||
export async function getSubProcessesUserInput (
|
||||
execution: Execution,
|
||||
space: Ref<Space>,
|
||||
transition: Transition,
|
||||
userContext: ExecutionContext
|
||||
@@ -509,8 +554,28 @@ export async function getSubProcessesUserInput (
|
||||
if (action.methodId !== process.method.RunSubProcess) continue
|
||||
const processId = action.params._id as Ref<Process>
|
||||
if (processId === undefined) continue
|
||||
const context = action.params.context ?? getEmptyContext()
|
||||
const res = await newExecutionUserInput(processId, space, context)
|
||||
const context: ExecutionContext = action.params.context ?? getEmptyContext()
|
||||
const initTransition = getClient().getModel().findAllSync(process.class.Transition, {
|
||||
process: processId,
|
||||
from: null
|
||||
})[0]
|
||||
if (initTransition === undefined) continue
|
||||
const card = action.params.card ?? execution.card
|
||||
const client = getClient()
|
||||
const mockExecution: Execution = {
|
||||
_id: generateId(),
|
||||
process: processId,
|
||||
currentState: null as any,
|
||||
card,
|
||||
rollback: [],
|
||||
context,
|
||||
status: ExecutionStatus.Active,
|
||||
space,
|
||||
_class: process.class.Execution,
|
||||
modifiedOn: 0,
|
||||
modifiedBy: client.user
|
||||
}
|
||||
const res = await newExecutionUserInput(processId, space, mockExecution)
|
||||
if (action.context == null || res === undefined) continue
|
||||
userContext[action.context._id] = res
|
||||
changed = true
|
||||
@@ -521,47 +586,76 @@ export async function getSubProcessesUserInput (
|
||||
export async function newExecutionUserInput (
|
||||
_id: Ref<Process>,
|
||||
space: Ref<Space>,
|
||||
userContext?: ExecutionContext
|
||||
): Promise<ExecutionContext | undefined> {
|
||||
execution: Execution
|
||||
): Promise<{ context: ExecutionContext, state: Ref<State>, changed: boolean } | undefined> {
|
||||
const client = getClient()
|
||||
const initTransition = client.getModel().findAllSync(process.class.Transition, {
|
||||
process: _id,
|
||||
from: null
|
||||
})[0]
|
||||
if (initTransition === undefined) return userContext
|
||||
return await requestUserInput(_id, space, initTransition, userContext ?? getEmptyContext())
|
||||
if (initTransition === undefined) return undefined
|
||||
return await requestUserInput(_id, space, initTransition, execution, execution.context)
|
||||
}
|
||||
|
||||
export async function getNextStateUserInput (
|
||||
execution: Execution,
|
||||
transition: Transition,
|
||||
userContext: ExecutionContext
|
||||
): Promise<ExecutionContext | undefined> {
|
||||
userContext: ExecutionContext,
|
||||
inputContext: Record<string, any> = {}
|
||||
): Promise<{ context: ExecutionContext, state: Ref<State>, changed: boolean } | undefined> {
|
||||
const client = getClient()
|
||||
const process = client.getModel().findObject(execution.process)
|
||||
if (process === undefined) return userContext
|
||||
return await requestUserInput(execution.process, execution.space, transition, userContext)
|
||||
const _process = client.getModel().findObject(execution.process)
|
||||
if (_process === undefined) return undefined
|
||||
return await requestUserInput(execution.process, execution.space, transition, execution, userContext, inputContext)
|
||||
}
|
||||
|
||||
export async function createExecution (card: Ref<Card>, _id: Ref<Process>, space: Ref<Space>): Promise<void> {
|
||||
export async function createExecution (
|
||||
card: Ref<Card>,
|
||||
_id: Ref<Process>,
|
||||
space: Ref<Space>,
|
||||
txFactory: TxFactory
|
||||
): Promise<TxCUD<Doc> | undefined> {
|
||||
const client = getClient()
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const context = await newExecutionUserInput(_id, space)
|
||||
|
||||
const _process = client.getModel().findObject(_id)
|
||||
if (_process === undefined) return
|
||||
|
||||
const initTransition = client.getModel().findAllSync(process.class.Transition, {
|
||||
process: _id,
|
||||
from: null
|
||||
})[0]
|
||||
if (initTransition === undefined) return
|
||||
await client.createDoc(process.class.Execution, space, {
|
||||
const executionId = generateId<Execution>()
|
||||
|
||||
const mockExecution: Execution = {
|
||||
_id: executionId,
|
||||
process: _id,
|
||||
currentState: initTransition.to,
|
||||
currentState: null as any,
|
||||
card,
|
||||
rollback: [],
|
||||
context: context ?? getEmptyContext(),
|
||||
status: ExecutionStatus.Active
|
||||
})
|
||||
context: getEmptyContext(),
|
||||
status: ExecutionStatus.Active,
|
||||
space,
|
||||
_class: process.class.Execution,
|
||||
modifiedOn: 0,
|
||||
modifiedBy: client.user
|
||||
}
|
||||
|
||||
const result = await newExecutionUserInput(_id, space, mockExecution)
|
||||
|
||||
return txFactory.createTxCreateDoc(
|
||||
process.class.Execution,
|
||||
space,
|
||||
{
|
||||
process: _id,
|
||||
currentState: null as any,
|
||||
card,
|
||||
rollback: [],
|
||||
context: result?.context ?? getEmptyContext(),
|
||||
status: ExecutionStatus.Active
|
||||
},
|
||||
executionId
|
||||
)
|
||||
}
|
||||
|
||||
export function getToDoEndAction (prevState: State): Step<Doc> {
|
||||
@@ -588,11 +682,10 @@ export function getToDoEndAction (prevState: State): Step<Doc> {
|
||||
}
|
||||
|
||||
export async function requestResult (
|
||||
txop: TxOperations,
|
||||
execution: Execution,
|
||||
results: UserResult[] | undefined,
|
||||
context: ExecutionContext
|
||||
): Promise<void> {
|
||||
): Promise<ExecutionContext | undefined> {
|
||||
if (results == null || results.length === 0) return
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
showPopup(process.component.ResultInput, { results, context }, undefined, (res) => {
|
||||
@@ -608,9 +701,7 @@ export async function requestResult (
|
||||
})
|
||||
})
|
||||
await promise
|
||||
await txop.update(execution, {
|
||||
context
|
||||
})
|
||||
return context
|
||||
}
|
||||
|
||||
export function todoTranstionCheck (
|
||||
@@ -620,7 +711,16 @@ export function todoTranstionCheck (
|
||||
context: Record<string, any>
|
||||
): boolean {
|
||||
if (params._id === undefined) return false
|
||||
return context.todo?._id === params._id
|
||||
return context.todo?._id === params._id && checkResult(context, params.result)
|
||||
}
|
||||
|
||||
function checkResult (context: Record<string, any>, results: Record<string, any> | undefined): boolean {
|
||||
if (results === undefined) return true
|
||||
for (const [key, value] of Object.entries(results)) {
|
||||
const res = findProperty([context as any], key, value)
|
||||
if (res.length === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function timeTransitionCheck (
|
||||
@@ -683,8 +783,9 @@ export function fieldChangesCheck (
|
||||
): boolean {
|
||||
const doc = context.card
|
||||
if (doc === undefined) return false
|
||||
const param = Object.keys(params)[0]
|
||||
if (!Object.keys(context.operations ?? {}).includes(param)) return false
|
||||
const operations = (context.operations ?? {}) as DocumentUpdate<Doc>
|
||||
const target = Object.keys(params)[0]
|
||||
if (!TxProcessor.hasUpdate(operations, target)) return false
|
||||
const res = matchQuery([doc], params, doc._class, client.getHierarchy(), true)
|
||||
return res.length > 0
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Process extends Doc {
|
||||
description: string
|
||||
parallelExecutionForbidden?: boolean
|
||||
autoStart?: boolean
|
||||
automationOnly?: boolean
|
||||
context: Record<ContextId, ProcessContext>
|
||||
resultType?: Type<any>
|
||||
}
|
||||
@@ -123,6 +124,7 @@ export interface ProcessToDo extends ToDo {
|
||||
withRollback: boolean
|
||||
|
||||
results?: UserResult[]
|
||||
field?: string
|
||||
}
|
||||
|
||||
export interface ApproveRequest extends ProcessToDo {
|
||||
@@ -133,6 +135,8 @@ export interface ApproveRequest extends ProcessToDo {
|
||||
reason?: string
|
||||
|
||||
group: string
|
||||
field?: string
|
||||
actionType?: 'approve' | 'review'
|
||||
}
|
||||
|
||||
export type MethodParams<T extends Doc> = {
|
||||
@@ -251,7 +255,9 @@ export default plugin(processId, {
|
||||
LockCard: '' as Ref<Method<Card>>,
|
||||
LockSection: '' as Ref<Method<Card>>,
|
||||
UnlockCard: '' as Ref<Method<Card>>,
|
||||
UnlockSection: '' as Ref<Method<Card>>
|
||||
UnlockSection: '' as Ref<Method<Card>>,
|
||||
LockField: '' as Ref<Method<Card>>,
|
||||
UnlockField: '' as Ref<Method<Card>>
|
||||
},
|
||||
trigger: {
|
||||
OnCardUpdate: '' as Ref<Trigger>, // in fact WhenCardMatches, should migrate in future
|
||||
@@ -290,7 +296,12 @@ export default plugin(processId, {
|
||||
Actions: '' as IntlString,
|
||||
ProcessRunned: '' as IntlString,
|
||||
ProcessStateChanged: '' as IntlString,
|
||||
ProcessFinished: '' as IntlString
|
||||
ProcessFinished: '' as IntlString,
|
||||
SyncWithField: '' as IntlString,
|
||||
ActionType: '' as IntlString,
|
||||
ApproveAction: '' as IntlString,
|
||||
ReviewAction: '' as IntlString,
|
||||
Review: '' as IntlString
|
||||
},
|
||||
error: {
|
||||
MethodNotFound: '' as IntlString,
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
"Hidden": "Skryté",
|
||||
"Configure": "Konfigurovat",
|
||||
"InviteSettings": "Nastavení pozvánek",
|
||||
"RoleCapabilitySettings": "Oprávnění rolí",
|
||||
"DefaultInviteRoleForJoin": "Výchozí role při připojení přes odkaz s pozvánkou:",
|
||||
"InviteLinkGeneratorRoles": "Vyberte uživatelské role, které mohou vytvářet odkazy s pozvánkou:",
|
||||
"DefaultValue": "Výchozí hodnota",
|
||||
"SelectAValue": "Vyberte hodnotu",
|
||||
"DateOnly": "Pouze datum",
|
||||
@@ -119,6 +122,8 @@
|
||||
"WorkspaceName": "Název pracovního prostoru",
|
||||
"Workspace": "Pracovní prostor",
|
||||
"OwnerOrMaintainerRequired": "Musíte být vlastníkem nebo správcem pracovního prostoru",
|
||||
"LastOwnerLeaveTitle": "Nelze opustit pracovní prostor",
|
||||
"LastOwnerLeaveMessage": "Jste jediným vlastníkem tohoto pracovního prostoru. Abyste jej mohli opustit, nejprve předejte oprávnění vlastníka jinému členovi. Pokud tento pracovní prostor už nikdo nepotřebuje, zvažte jeho smazání.",
|
||||
"Backup": "Záloha",
|
||||
"BackupLast": "Poslední záloha",
|
||||
"BackupTotalSnapshots": "Celkový počet snímků",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Ausgeblendet",
|
||||
"Configure": "Konfigurieren",
|
||||
"InviteSettings": "Einladungseinstellungen",
|
||||
"RoleCapabilitySettings": "Rollenberechtigungen",
|
||||
"DefaultInviteRoleForJoin": "Standardrolle nach dem Beitritt über Einladungslink:",
|
||||
"InviteLinkGeneratorRoles": "Benutzerrollen auswählen, die Einladungslinks erstellen dürfen:",
|
||||
"DefaultValue": "Standardwert",
|
||||
"SelectAValue": "Wert auswählen",
|
||||
"DateOnly": "Nur Datum",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "Arbeitsbereich-Name",
|
||||
"Workspace": "Arbeitsbereich",
|
||||
"OwnerOrMaintainerRequired": "Sie müssen Eigentümer oder Betreuer des Arbeitsbereichs sein",
|
||||
"LastOwnerLeaveTitle": "Arbeitsbereich kann nicht verlassen werden",
|
||||
"LastOwnerLeaveMessage": "Sie sind der einzige Eigentümer dieses Arbeitsbereichs. Um ihn zu verlassen, übertragen Sie die Eigentümerrechte zunächst auf ein anderes Mitglied. Wenn niemand diesen Arbeitsbereich mehr benötigt, sollten Sie ihn stattdessen löschen.",
|
||||
"Backup": "Sicherung",
|
||||
"BackupLast": "Letzte Sicherung",
|
||||
"BackupTotalSnapshots": "Gesamtanzahl der Schnappschüsse",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Hidden",
|
||||
"Configure": "Configure",
|
||||
"InviteSettings": "Invite settings",
|
||||
"RoleCapabilitySettings": "Role permissions",
|
||||
"DefaultInviteRoleForJoin": "Default role assigned after joining via invite link:",
|
||||
"InviteLinkGeneratorRoles": "Select user roles who can generate invite links:",
|
||||
"DefaultValue": "Default value",
|
||||
"SelectAValue": "Select a value",
|
||||
"DateOnly": "Date only",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "Workspace name",
|
||||
"Workspace": "Workspace",
|
||||
"OwnerOrMaintainerRequired": "You need to be a workspace Owner or Maintainer",
|
||||
"LastOwnerLeaveTitle": "Cannot leave workspace",
|
||||
"LastOwnerLeaveMessage": "You are the only owner of this workspace. To leave, first grant owner permissions to another member. If nobody needs this workspace anymore, consider deleting this workspace instead.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Last backup",
|
||||
"BackupTotalSnapshots": "Total Snapshots",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Oculto",
|
||||
"Configure": "Configurar",
|
||||
"InviteSettings": "Ajustes de invitación",
|
||||
"RoleCapabilitySettings": "Permisos de rol",
|
||||
"DefaultInviteRoleForJoin": "Rol predeterminado asignado al unirse mediante enlace de invitación:",
|
||||
"InviteLinkGeneratorRoles": "Selecciona los roles de usuario que pueden generar enlaces de invitación:",
|
||||
"DefaultValue": "Valor predeterminado",
|
||||
"SelectAValue": "Seleccionar un valor",
|
||||
"DateOnly": "Solo fecha",
|
||||
@@ -116,6 +119,8 @@
|
||||
"WorkspaceName": "Nombre del espacio de trabajo",
|
||||
"Workspace": "Espacio de trabajo",
|
||||
"OwnerOrMaintainerRequired": "Necesitas ser Propietario o Mantenedor del espacio de trabajo",
|
||||
"LastOwnerLeaveTitle": "No puedes abandonar el espacio de trabajo",
|
||||
"LastOwnerLeaveMessage": "Eres el único propietario de este espacio de trabajo. Para salir, primero asigna permisos de propietario a otro miembro. Si nadie más necesita este espacio de trabajo, considera eliminarlo.",
|
||||
"Backup": "Copia de seguridad",
|
||||
"BackupLast": "Última copia de seguridad",
|
||||
"BackupTotalSnapshots": "Total instantáneas",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Caché",
|
||||
"Configure": "Configurer",
|
||||
"InviteSettings": "Paramètres d'invitation",
|
||||
"RoleCapabilitySettings": "Autorisations de rôle",
|
||||
"DefaultInviteRoleForJoin": "Rôle attribué par défaut après avoir rejoint via un lien d'invitation :",
|
||||
"InviteLinkGeneratorRoles": "Sélectionnez les rôles utilisateurs pouvant générer des liens d'invitation :",
|
||||
"DefaultValue": "Valeur par défaut",
|
||||
"SelectAValue": "Sélectionnez une valeur",
|
||||
"DateOnly": "Date uniquement",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "Nom de l'espace de travail",
|
||||
"Workspace": "Espace de travail",
|
||||
"OwnerOrMaintainerRequired": "Vous devez être propriétaire ou responsable d'un espace de travail",
|
||||
"LastOwnerLeaveTitle": "Impossible de quitter l'espace de travail",
|
||||
"LastOwnerLeaveMessage": "Vous êtes le seul propriétaire de cet espace de travail. Pour le quitter, attribuez d'abord les droits de propriétaire à un autre membre. Si personne n'a plus besoin de cet espace de travail, envisagez de le supprimer.",
|
||||
"Backup": "Sauvegarde",
|
||||
"BackupLast": "Dernière sauvegarde",
|
||||
"BackupTotalSnapshots": "Total des instantanés",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Nascosto",
|
||||
"Configure": "Configura",
|
||||
"InviteSettings": "Impostazioni invito",
|
||||
"RoleCapabilitySettings": "Autorizzazioni ruolo",
|
||||
"DefaultInviteRoleForJoin": "Ruolo predefinito assegnato dopo l'accesso tramite link di invito:",
|
||||
"InviteLinkGeneratorRoles": "Seleziona i ruoli utente che possono generare link di invito:",
|
||||
"DefaultValue": "Valore predefinito",
|
||||
"SelectAValue": "Seleziona un valore",
|
||||
"DateOnly": "Solo data",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "Nome spazio di lavoro",
|
||||
"Workspace": "Spazio di lavoro",
|
||||
"OwnerOrMaintainerRequired": "Devi essere un proprietario o un manutentore dello spazio di lavoro",
|
||||
"LastOwnerLeaveTitle": "Impossibile uscire dallo spazio di lavoro",
|
||||
"LastOwnerLeaveMessage": "Sei l'unico proprietario di questo spazio di lavoro. Per uscire, assegna prima i permessi di proprietario a un altro membro. Se nessuno ha più bisogno di questo spazio di lavoro, valuta di eliminarlo.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Ultimo backup",
|
||||
"BackupTotalSnapshots": "Totale istantanee",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "非表示",
|
||||
"Configure": "構成",
|
||||
"InviteSettings": "招待設定",
|
||||
"RoleCapabilitySettings": "ロール権限",
|
||||
"DefaultInviteRoleForJoin": "招待リンクから参加した後に割り当てる既定のロール:",
|
||||
"InviteLinkGeneratorRoles": "招待リンクを生成できるユーザーロールを選択:",
|
||||
"DefaultValue": "既定値",
|
||||
"SelectAValue": "値を選択",
|
||||
"DateOnly": "日付のみ",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "ワークスペース名",
|
||||
"Workspace": "ワークスペース",
|
||||
"OwnerOrMaintainerRequired": "ワークスペースの所有者またはメンテナーである必要があります",
|
||||
"LastOwnerLeaveTitle": "ワークスペースから退出できません",
|
||||
"LastOwnerLeaveMessage": "あなたはこのワークスペースの唯一の所有者です。退出するには、先に別のメンバーに所有者権限を付与してください。このワークスペースが誰にも必要ない場合は、削除することも検討してください。",
|
||||
"Backup": "バックアップ",
|
||||
"BackupLast": "最後のバックアップ",
|
||||
"BackupTotalSnapshots": "合計スナップショット",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Oculto",
|
||||
"Configure": "Configurar",
|
||||
"InviteSettings": "Configurações de convite",
|
||||
"RoleCapabilitySettings": "Permissões de função",
|
||||
"DefaultInviteRoleForJoin": "Função padrão atribuída após entrar por link de convite:",
|
||||
"InviteLinkGeneratorRoles": "Selecione as funções de usuário que podem gerar links de convite:",
|
||||
"DefaultValue": "Valor padrão",
|
||||
"SelectAValue": "Selecionar um valor",
|
||||
"DateOnly": "Somente data",
|
||||
@@ -116,6 +119,8 @@
|
||||
"WorkspaceName": "Nome do espaço de trabalho",
|
||||
"Workspace": "Espaço de trabalho",
|
||||
"OwnerOrMaintainerRequired": "Precisa ser proprietário ou mantenedor do espaço de trabalho",
|
||||
"LastOwnerLeaveTitle": "Não é possível sair do espaço de trabalho",
|
||||
"LastOwnerLeaveMessage": "Você é o único proprietário deste espaço de trabalho. Para sair, primeiro conceda permissões de proprietário a outro membro. Se ninguém mais precisar deste espaço de trabalho, considere excluí-lo.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Último backup",
|
||||
"BackupTotalSnapshots": "Total de Snapshots",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Oculto",
|
||||
"Configure": "Configurar",
|
||||
"InviteSettings": "Configurações de convite",
|
||||
"RoleCapabilitySettings": "Permissões de função",
|
||||
"DefaultInviteRoleForJoin": "Função padrão atribuída após entrar por link de convite:",
|
||||
"InviteLinkGeneratorRoles": "Selecione as funções de usuário que podem gerar links de convite:",
|
||||
"DefaultValue": "Valor padrão",
|
||||
"SelectAValue": "Selecionar um valor",
|
||||
"DateOnly": "Somente data",
|
||||
@@ -116,6 +119,8 @@
|
||||
"WorkspaceName": "Nome do espaço de trabalho",
|
||||
"Workspace": "Espaço de trabalho",
|
||||
"OwnerOrMaintainerRequired": "Precisa de ser proprietário ou mantenedor do espaço de trabalho",
|
||||
"LastOwnerLeaveTitle": "Não é possível sair do espaço de trabalho",
|
||||
"LastOwnerLeaveMessage": "É o único proprietário deste espaço de trabalho. Para sair, primeiro conceda permissões de proprietário a outro membro. Se ninguém mais precisar deste espaço de trabalho, considere eliminar o espaço de trabalho.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Último backup",
|
||||
"BackupTotalSnapshots": "Total de Snapshots",
|
||||
|
||||
@@ -94,6 +94,9 @@
|
||||
"Hidden": "Спрятанный",
|
||||
"Configure": "Настроить",
|
||||
"InviteSettings": "Настройки приглашений",
|
||||
"RoleCapabilitySettings": "Настройки доступа",
|
||||
"DefaultInviteRoleForJoin": "Роль по умолчанию после присоединения по ссылке-приглашению:",
|
||||
"InviteLinkGeneratorRoles": "Выберите роли пользователей, которые могут создавать ссылки-приглашения:",
|
||||
"DefaultValue": "Значение по умолчанию",
|
||||
"SelectAValue": "Выбрать значение",
|
||||
"DateOnly": "Только дата",
|
||||
@@ -129,6 +132,8 @@
|
||||
"WorkspaceName": "Название рабочего пространства",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого",
|
||||
"LastOwnerLeaveTitle": "Невозможно выйти из рабочего пространства",
|
||||
"LastOwnerLeaveMessage": "Вы являетесь единственным владельцем этого рабочего пространства. Чтобы выйти, сначала назначьте владельцем другого участника. Если это рабочее пространство больше никому не нужно, рассмотрите возможность его удаления.",
|
||||
"Backup": "Резервное копирование",
|
||||
"BackupLast": "Последнее резервное копирование",
|
||||
"BackupTotalSnapshots": "Всего снимков",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "Gizli",
|
||||
"Configure": "Yapılandır",
|
||||
"InviteSettings": "Davet ayarları",
|
||||
"RoleCapabilitySettings": "Rol izinleri",
|
||||
"DefaultInviteRoleForJoin": "Davet bağlantısı ile katıldıktan sonra atanacak varsayılan rol:",
|
||||
"InviteLinkGeneratorRoles": "Davet bağlantısı oluşturabilecek kullanıcı rollarını seçin:",
|
||||
"DefaultValue": "Varsayılan değer",
|
||||
"SelectAValue": "Bir değer seç",
|
||||
"DateOnly": "Sadece tarih",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "Çalışma alanı adı",
|
||||
"Workspace": "Çalışma alanı",
|
||||
"OwnerOrMaintainerRequired": "Çalışma alanı Sahibi veya Bakımcısı olmanız gerekiyor",
|
||||
"LastOwnerLeaveTitle": "Çalışma alanından ayrılamazsınız",
|
||||
"LastOwnerLeaveMessage": "Bu çalışma alanının tek sahibisiniz. Ayrılmak için önce başka bir üyeye sahiplik yetkisi verin. Bu çalışma alanına artık kimsenin ihtiyacı yoksa, silmeyi de düşünebilirsiniz.",
|
||||
"Backup": "Yedek",
|
||||
"BackupLast": "Son yedekleme",
|
||||
"BackupTotalSnapshots": "Toplam Anlık Görüntü",
|
||||
|
||||
@@ -90,6 +90,9 @@
|
||||
"Hidden": "隐藏",
|
||||
"Configure": "配置",
|
||||
"InviteSettings": "邀请设置",
|
||||
"RoleCapabilitySettings": "角色权限",
|
||||
"DefaultInviteRoleForJoin": "通过邀请链接加入后分配的默认角色:",
|
||||
"InviteLinkGeneratorRoles": "选择可以生成邀请链接的用户角色:",
|
||||
"DefaultValue": "默认值",
|
||||
"SelectAValue": "选择一个值",
|
||||
"DateOnly": "仅日期",
|
||||
@@ -125,6 +128,8 @@
|
||||
"WorkspaceName": "工作区名称",
|
||||
"Workspace": "工作区",
|
||||
"OwnerOrMaintainerRequired": "您需要成为工作空间所有者或维护者",
|
||||
"LastOwnerLeaveTitle": "无法离开工作区",
|
||||
"LastOwnerLeaveMessage": "您是此工作区的唯一所有者。要离开,请先将所有者权限授予其他成员。如果不再有人需要此工作区,请考虑删除该工作区。",
|
||||
"Backup": "备份",
|
||||
"BackupLast": "上次备份",
|
||||
"BackupTotalSnapshots": "快照总数",
|
||||
|
||||
@@ -138,13 +138,13 @@
|
||||
...extra.map((it) => ({
|
||||
label: it.label,
|
||||
icon: it.icon,
|
||||
action: async (_: any, evt: Event) => {
|
||||
action: async (evtArg: unknown, evt: Event): Promise<void> => {
|
||||
const r = await getResource(it.action)
|
||||
await r(attribute, evt, it.actionProps)
|
||||
}
|
||||
}))
|
||||
)
|
||||
showPopup(Menu, { actions }, getEventPositionElement(ev), () => {
|
||||
void showPopup(Menu, { actions }, getEventPositionElement(ev), () => {
|
||||
hovered = null
|
||||
})
|
||||
}
|
||||
@@ -155,8 +155,10 @@
|
||||
return client.getHierarchy().getClass((type as RefTo<Doc>).to)?.label
|
||||
case core.class.Collection:
|
||||
return client.getHierarchy().getClass((type as Collection<AttachedDoc>).of)?.label
|
||||
case core.class.ArrOf:
|
||||
return (type as ArrOf<Doc>).of?.label
|
||||
case core.class.ArrOf: {
|
||||
const arrOf = type as ArrOf<Doc>
|
||||
return arrOf.of !== undefined && arrOf.of !== null ? arrOf.of.label : undefined
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
return false
|
||||
}
|
||||
|
||||
function dragover (ev: MouseEvent, item: string) {
|
||||
function dragover (ev: MouseEvent, item: string): void {
|
||||
const s = values.findIndex((p) => p === selected)
|
||||
const i = values.findIndex((p) => p === item)
|
||||
if (dragswap(ev, item)) {
|
||||
@@ -55,11 +55,11 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function remove (target: string) {
|
||||
async function remove (target: string): Promise<void> {
|
||||
dispatch('remove', target)
|
||||
}
|
||||
|
||||
async function onDrop () {
|
||||
async function onDrop (): Promise<void> {
|
||||
dispatch('drop')
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
label: setting.string.Delete,
|
||||
action: () => {
|
||||
if (opened !== undefined) {
|
||||
remove(values[opened])
|
||||
void remove(values[opened])
|
||||
opened = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@
|
||||
import settingsRes from '../plugin'
|
||||
import ApiTokenPopup from './ApiTokenPopup.svelte'
|
||||
import WorkspacePermissionEditor from './WorkspacePermissionEditor.svelte'
|
||||
import de from 'date-fns/locale/de'
|
||||
|
||||
let loading = true
|
||||
let isEditingName = false
|
||||
|
||||
@@ -13,34 +13,112 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import core from '@hcengineering/core'
|
||||
import core, { AccountRole, getCurrentAccount, type Ref } from '@hcengineering/core'
|
||||
import login from '@hcengineering/login'
|
||||
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import setting, { InviteSettings } from '@hcengineering/setting'
|
||||
import { Breadcrumb, Button, EditBox, Header, MiniToggle } from '@hcengineering/ui'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import setting, { type InviteSettings, type RoleCapabilitySettings, RoleCapability } from '@hcengineering/setting'
|
||||
import { hasRoleCapability } from '../roleCapability'
|
||||
import { translate } from '@hcengineering/platform'
|
||||
import {
|
||||
Breadcrumb,
|
||||
DropdownLabels,
|
||||
DropdownTextItem,
|
||||
EditBox,
|
||||
Header,
|
||||
Label,
|
||||
Loading,
|
||||
Scroller,
|
||||
themeStore,
|
||||
Toggle
|
||||
} from '@hcengineering/ui'
|
||||
import settingRes from '../plugin'
|
||||
import UserRoleSelect from './UserRoleSelect.svelte'
|
||||
|
||||
const client = getClient()
|
||||
let loading = true
|
||||
let expTime: number = 48
|
||||
let mask: string = ''
|
||||
let limit: number = -1
|
||||
let limit: number | undefined = -1
|
||||
let defaultInviteRole: AccountRole = AccountRole.User
|
||||
let inviteLinkGeneratorRoles: AccountRole[] = [AccountRole.User, AccountRole.Maintainer, AccountRole.Owner]
|
||||
let noLimit: boolean = true
|
||||
let existingInviteSettings: InviteSettings[]
|
||||
let existingInviteSettings: InviteSettings[] = []
|
||||
let existingRoleCapabilitySettings: {
|
||||
_id: Ref<RoleCapabilitySettings>
|
||||
roleByCapability?: Record<string, AccountRole[]>
|
||||
}[] = []
|
||||
const query = createQuery()
|
||||
|
||||
$: query.query(setting.class.InviteSettings, {}, (set) => {
|
||||
existingInviteSettings = set
|
||||
if (existingInviteSettings !== undefined && existingInviteSettings.length > 0) {
|
||||
expTime = existingInviteSettings[0].expirationTime
|
||||
mask = existingInviteSettings[0].emailMask
|
||||
limit = existingInviteSettings[0].limit
|
||||
}
|
||||
const roleCapabilityQuery = createQuery()
|
||||
roleCapabilityQuery.query(setting.class.RoleCapabilitySettings, {}, (set) => {
|
||||
existingRoleCapabilitySettings = set as typeof existingRoleCapabilitySettings
|
||||
})
|
||||
$: inviteLimitInvalid = !noLimit && (limit === undefined || Number.isNaN(limit))
|
||||
$: roleByCapability = existingRoleCapabilitySettings[0]?.roleByCapability
|
||||
$: canManagePermissions = hasRoleCapability(
|
||||
getCurrentAccount(),
|
||||
RoleCapability.ManageInviteSettings,
|
||||
roleByCapability,
|
||||
undefined
|
||||
)
|
||||
const defaultGeneratorRoles: AccountRole[] = [AccountRole.User, AccountRole.Maintainer, AccountRole.Owner]
|
||||
|
||||
let inviteLinkGeneratorRolesItems: DropdownTextItem[] = []
|
||||
$: lang = $themeStore?.language
|
||||
$: if (typeof lang === 'string') {
|
||||
void Promise.all([
|
||||
translate(settingRes.string.User, {}, lang),
|
||||
translate(settingRes.string.Maintainer, {}, lang),
|
||||
translate(settingRes.string.Owner, {}, lang)
|
||||
]).then(([userLabel, maintainerLabel, ownerLabel]) => {
|
||||
inviteLinkGeneratorRolesItems = [
|
||||
{ id: AccountRole.User, label: userLabel },
|
||||
{ id: AccountRole.Maintainer, label: maintainerLabel },
|
||||
{ id: AccountRole.Owner, label: ownerLabel }
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function applyInviteSettings (set: InviteSettings[]): void {
|
||||
existingInviteSettings = set
|
||||
if (existingInviteSettings.length > 0) {
|
||||
const first = existingInviteSettings[0]
|
||||
expTime = first.expirationTime
|
||||
mask = first.emailMask
|
||||
limit = first.limit
|
||||
defaultInviteRole = first.defaultInviteRole ?? AccountRole.User
|
||||
inviteLinkGeneratorRoles =
|
||||
first.inviteLinkGeneratorRoles != null && first.inviteLinkGeneratorRoles.length > 0
|
||||
? [...first.inviteLinkGeneratorRoles]
|
||||
: [...defaultGeneratorRoles]
|
||||
} else {
|
||||
expTime = 48
|
||||
mask = ''
|
||||
limit = -1
|
||||
defaultInviteRole = AccountRole.User
|
||||
inviteLinkGeneratorRoles = [...defaultGeneratorRoles]
|
||||
}
|
||||
noLimit = limit === -1
|
||||
loading = false
|
||||
}
|
||||
|
||||
$: query.query(setting.class.InviteSettings, {}, applyInviteSettings)
|
||||
|
||||
function normalizeValues (): void {
|
||||
expTime = Math.max(1, expTime)
|
||||
limit = noLimit ? -1 : Math.max(1, limit ?? 1)
|
||||
}
|
||||
|
||||
async function setInviteSettings (): Promise<void> {
|
||||
if (inviteLimitInvalid) return
|
||||
normalizeValues()
|
||||
const savedLimit = limit ?? -1
|
||||
|
||||
const newSettings = {
|
||||
expirationTime: expTime,
|
||||
emailMask: mask,
|
||||
limit,
|
||||
limit: savedLimit,
|
||||
defaultInviteRole,
|
||||
inviteLinkGeneratorRoles: [...inviteLinkGeneratorRoles],
|
||||
enabled: true
|
||||
}
|
||||
if (existingInviteSettings.length === 0) {
|
||||
@@ -53,33 +131,162 @@
|
||||
newSettings
|
||||
)
|
||||
}
|
||||
const newRoleByCapability: Record<string, AccountRole[]> = {
|
||||
...(existingRoleCapabilitySettings[0]?.roleByCapability ?? {}),
|
||||
[RoleCapability.GenerateInviteLink]: [...inviteLinkGeneratorRoles]
|
||||
}
|
||||
const roleCapabilityPayload = { roleByCapability: newRoleByCapability, enabled: true }
|
||||
if (existingRoleCapabilitySettings.length === 0) {
|
||||
await client.createDoc(setting.class.RoleCapabilitySettings, core.space.Workspace, roleCapabilityPayload)
|
||||
} else {
|
||||
await client.updateDoc(
|
||||
setting.class.RoleCapabilitySettings,
|
||||
core.space.Workspace,
|
||||
existingRoleCapabilitySettings[0]._id,
|
||||
roleCapabilityPayload
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function autoSaveIfValid (): Promise<void> {
|
||||
if (loading || inviteLimitInvalid) return
|
||||
await setInviteSettings()
|
||||
}
|
||||
|
||||
function handleNoLimitChange (e: CustomEvent<boolean>): void {
|
||||
noLimit = e.detail
|
||||
if (noLimit) {
|
||||
limit = -1
|
||||
} else if (limit === undefined || Number.isNaN(limit) || limit < 1) {
|
||||
limit = 1
|
||||
}
|
||||
void autoSaveIfValid()
|
||||
}
|
||||
|
||||
function handleExpTimeChange (): void {
|
||||
void autoSaveIfValid()
|
||||
}
|
||||
|
||||
function handleLimitChange (): void {
|
||||
void autoSaveIfValid()
|
||||
}
|
||||
|
||||
function handleDefaultInviteRoleSelected (e: CustomEvent<AccountRole>): void {
|
||||
defaultInviteRole = e.detail
|
||||
void autoSaveIfValid()
|
||||
}
|
||||
|
||||
function handleGeneratorRolesSelected (e: CustomEvent<AccountRole[] | undefined>): void {
|
||||
if (e.detail != null) {
|
||||
inviteLinkGeneratorRoles = [...e.detail]
|
||||
}
|
||||
void autoSaveIfValid()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="hulyComponent">
|
||||
<Header adaptive={'disabled'}>
|
||||
<Breadcrumb icon={setting.icon.InviteSettings} label={setting.string.InviteSettings} size={'large'} isCurrent />
|
||||
<Breadcrumb icon={setting.icon.InviteSettings} label={settingRes.string.InviteSettings} size={'large'} isCurrent />
|
||||
</Header>
|
||||
<div class="form">
|
||||
<div class="mt-2">
|
||||
<EditBox label={login.string.LinkValidHours} format={'number'} bind:value={expTime} />
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<MiniToggle bind:on={noLimit} label={login.string.NoLimit} on:change={() => noLimit && (limit = -1)} />
|
||||
</div>
|
||||
{#if !noLimit}
|
||||
<div class="mt-2">
|
||||
<EditBox label={login.string.InviteLimit} format={'number'} bind:value={limit} />
|
||||
<div class="hulyComponent-content__column content">
|
||||
{#if loading}
|
||||
<div class="w-full h-full flex-col-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
{:else}
|
||||
<Scroller align={'center'} padding={'var(--spacing-3)'} bottomPadding={'var(--spacing-3)'}>
|
||||
<div class="hulyComponent-content flex-col flex-gap-4">
|
||||
<div class="title"><Label label={settingRes.string.InviteSettings} /></div>
|
||||
|
||||
<div class="settings-list mt-6">
|
||||
<div class="setting-row">
|
||||
<Label label={login.string.LinkValidHours} />
|
||||
<div class="max-w-60">
|
||||
<EditBox
|
||||
format={'number'}
|
||||
minValue={1}
|
||||
maxDigitsAfterPoint={0}
|
||||
bind:value={expTime}
|
||||
on:change={handleExpTimeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<Label label={login.string.NoLimit} />
|
||||
<Toggle on={noLimit} on:change={handleNoLimitChange} />
|
||||
</div>
|
||||
|
||||
{#if !noLimit}
|
||||
<div class="setting-row">
|
||||
<Label label={login.string.InviteLimit} />
|
||||
<div class="max-w-60">
|
||||
<EditBox
|
||||
format={'number'}
|
||||
minValue={1}
|
||||
maxDigitsAfterPoint={0}
|
||||
bind:value={limit}
|
||||
on:change={handleLimitChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="setting-row mt-4">
|
||||
<Label label={settingRes.string.DefaultInviteRoleForJoin} />
|
||||
<div class="max-w-60">
|
||||
<UserRoleSelect selected={defaultInviteRole} on:selected={handleDefaultInviteRoleSelected} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if canManagePermissions}
|
||||
<div class="setting-row mt-4">
|
||||
<div class="title"><Label label={settingRes.string.Permissions} /></div>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<Label label={settingRes.string.InviteLinkGeneratorRoles} />
|
||||
<div class="max-w-60 flex-grow">
|
||||
<DropdownLabels
|
||||
label={settingRes.string.InviteLinkGeneratorRoles}
|
||||
items={inviteLinkGeneratorRolesItems}
|
||||
selected={inviteLinkGeneratorRoles}
|
||||
multiselect
|
||||
autoSelect={false}
|
||||
kind={'regular'}
|
||||
size={'medium'}
|
||||
width={'100%'}
|
||||
on:selected={handleGeneratorRolesSelected}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Scroller>
|
||||
{/if}
|
||||
<div class="mt-2">
|
||||
<Button label={presentation.string.Save} size={'medium'} kind={'primary'} on:click={() => setInviteSettings()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.form {
|
||||
padding: 1.5rem;
|
||||
.title {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
row-gap: 1rem;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-list .title {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
let existingOfficeSettings: OfficeSettings[] = []
|
||||
const query = createQuery()
|
||||
|
||||
$: query.query((setting.class as any).OfficeSettings, {}, (set) => {
|
||||
existingOfficeSettings = set as unknown as OfficeSettings[]
|
||||
$: query.query(setting.class.OfficeSettings, {}, (set) => {
|
||||
existingOfficeSettings = set as OfficeSettings[]
|
||||
if (existingOfficeSettings !== undefined && existingOfficeSettings.length > 0) {
|
||||
defaultStartWithTranscription = existingOfficeSettings[0].defaultStartWithTranscription ?? false
|
||||
defaultStartWithRecording = existingOfficeSettings[0].defaultStartWithRecording ?? false
|
||||
@@ -44,10 +44,10 @@
|
||||
enabled: true
|
||||
}
|
||||
if (existingOfficeSettings.length === 0) {
|
||||
await client.createDoc((setting.class as any).OfficeSettings, core.space.Workspace, newSettings)
|
||||
await client.createDoc(setting.class.OfficeSettings, core.space.Workspace, newSettings)
|
||||
} else {
|
||||
await client.updateDoc(
|
||||
(setting.class as any).OfficeSettings,
|
||||
setting.class.OfficeSettings,
|
||||
core.space.Workspace,
|
||||
existingOfficeSettings[0]._id,
|
||||
newSettings
|
||||
@@ -64,10 +64,10 @@
|
||||
enabled: true
|
||||
}
|
||||
if (existingOfficeSettings.length === 0) {
|
||||
await client.createDoc((setting.class as any).OfficeSettings, core.space.Workspace, newSettings)
|
||||
await client.createDoc(setting.class.OfficeSettings, core.space.Workspace, newSettings)
|
||||
} else {
|
||||
await client.updateDoc(
|
||||
(setting.class as any).OfficeSettings,
|
||||
setting.class.OfficeSettings,
|
||||
core.space.Workspace,
|
||||
existingOfficeSettings[0]._id,
|
||||
newSettings
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
{@const personUuid = employee.personUuid ?? undefined}
|
||||
{@const role = personUuid !== undefined ? workspaceMembers[personUuid] : undefined}
|
||||
{#if personUuid !== undefined && role !== undefined && employee.name?.includes(search)}
|
||||
<div class="flex-row-center p-2 flex-no-shrink">
|
||||
<div class="flex-row-center p-2 flex-no-shrink" data-id="owners-member-row">
|
||||
<div class="p-1 min-w-80">
|
||||
<EmployeePresenter value={employee} disabled={false} />
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<script lang="ts">
|
||||
import contact, { combineName, getFirstName, getLastName } from '@hcengineering/contact'
|
||||
import { ChannelsEditor, EditableAvatar, myEmployeeStore } from '@hcengineering/contact-resources'
|
||||
import { getCurrentAccount, SocialIdType } from '@hcengineering/core'
|
||||
import { AccountRole, getCurrentAccount, SocialIdType } from '@hcengineering/core'
|
||||
import login, { loginId } from '@hcengineering/login'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import platform, { getResource, PlatformError } from '@hcengineering/platform'
|
||||
import { AttributeEditor, createQuery, getClient, hasResource, MessageBox } from '@hcengineering/presentation'
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
let avatarEditor: EditableAvatar
|
||||
async function onAvatarDone (e: any): Promise<void> {
|
||||
async function onAvatarDone (): Promise<void> {
|
||||
if ($myEmployeeStore === undefined) return
|
||||
|
||||
if ($myEmployeeStore.avatar != null) {
|
||||
@@ -82,14 +82,30 @@
|
||||
message: setting.string.LeaveDescr,
|
||||
action: async () => {
|
||||
const leaveWorkspace = await getResource(login.function.LeaveWorkspace)
|
||||
const loginInfo = await leaveWorkspace(account.uuid)
|
||||
try {
|
||||
const loginInfo = await leaveWorkspace(account.uuid)
|
||||
|
||||
if (loginInfo?.token != null) {
|
||||
await logIn(loginInfo)
|
||||
navigate({ path: [loginId, 'selectWorkspace'] })
|
||||
} else {
|
||||
await logOut()
|
||||
navigate({ path: [loginId] })
|
||||
if (loginInfo?.token != null) {
|
||||
await logIn(loginInfo)
|
||||
navigate({ path: [loginId, 'selectWorkspace'] })
|
||||
} else {
|
||||
await logOut()
|
||||
navigate({ path: [loginId] })
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err instanceof PlatformError &&
|
||||
err.status?.code === platform.status.Forbidden &&
|
||||
account.role === AccountRole.Owner
|
||||
) {
|
||||
showPopup(MessageBox, {
|
||||
label: setting.string.LastOwnerLeaveTitle,
|
||||
message: setting.string.LastOwnerLeaveMessage,
|
||||
canSubmit: false
|
||||
})
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
// Copyright © 2026 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 { AccountRole } from '@hcengineering/core'
|
||||
import { DropdownLabelsIntl, type DropdownIntlItem } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
import settingRes from '../plugin'
|
||||
|
||||
export let selected: AccountRole
|
||||
export let disabled: boolean = false
|
||||
export let securityFilter: boolean = true
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const allInviteRoleItems: DropdownIntlItem[] = [
|
||||
{ id: AccountRole.Guest, label: settingRes.string.Guest },
|
||||
{ id: AccountRole.User, label: settingRes.string.User },
|
||||
{ id: AccountRole.Maintainer, label: settingRes.string.Maintainer },
|
||||
{ id: AccountRole.Owner, label: settingRes.string.Owner }
|
||||
]
|
||||
|
||||
$: inviteRoleItems = securityFilter
|
||||
? allInviteRoleItems.filter((item) => item.id !== AccountRole.Owner && item.id !== AccountRole.Maintainer)
|
||||
: allInviteRoleItems
|
||||
|
||||
function handleSelected (e: CustomEvent<AccountRole>): void {
|
||||
dispatch('selected', e.detail)
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownLabelsIntl
|
||||
kind={'regular'}
|
||||
size={'medium'}
|
||||
items={inviteRoleItems}
|
||||
{selected}
|
||||
{disabled}
|
||||
on:selected={handleSelected}
|
||||
/>
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// Copyright © 2026 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 { InviteSettings, RoleCapabilityId, RoleCapabilitySettings } from '@hcengineering/setting'
|
||||
import { getCurrentAccount } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import setting, { RoleCapability } from '@hcengineering/setting'
|
||||
import { hasRoleCapability } from './roleCapability'
|
||||
|
||||
/**
|
||||
* Returns whether the current account has the given role capability.
|
||||
* Fetches InviteSettings and RoleCapabilitySettings from the client.
|
||||
* @public
|
||||
*/
|
||||
export async function hasRoleCapabilityAsync (capabilityId: RoleCapabilityId | string): Promise<boolean> {
|
||||
const client = getClient()
|
||||
const [inviteSettings, roleCapabilitySettings] = await Promise.all([
|
||||
client.findAll(setting.class.InviteSettings, {}, { limit: 1 }),
|
||||
client.findAll(setting.class.RoleCapabilitySettings, {}, { limit: 1 })
|
||||
])
|
||||
const firstInvite = inviteSettings[0] as InviteSettings | undefined
|
||||
const firstRoleCap = roleCapabilitySettings[0] as RoleCapabilitySettings | undefined
|
||||
const inviteLinkGeneratorRoles =
|
||||
capabilityId === RoleCapability.GenerateInviteLink &&
|
||||
firstInvite?.inviteLinkGeneratorRoles != null &&
|
||||
firstInvite.inviteLinkGeneratorRoles.length > 0
|
||||
? firstInvite.inviteLinkGeneratorRoles
|
||||
: undefined
|
||||
const roleByCapability = firstRoleCap?.roleByCapability
|
||||
const account = getCurrentAccount()
|
||||
return hasRoleCapability(account, capabilityId, roleByCapability, inviteLinkGeneratorRoles)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import ClassAttributes from './components/ClassAttributes.svelte'
|
||||
import ClassAttributesList from './components/ClassAttributesList.svelte'
|
||||
import Configure from './components/Configure.svelte'
|
||||
import InviteSetting from './components/InviteSetting.svelte'
|
||||
import UserRoleSelect from './components/UserRoleSelect.svelte'
|
||||
import PermissionPresenter from './components/presenters/PermissionPresenter.svelte'
|
||||
import AttributePermissionPresenter from './components/presenters/AttributePermissionPresenter.svelte'
|
||||
import ClassPermissionPresenter from './components/presenters/ClassPermissionPresenter.svelte'
|
||||
@@ -76,8 +77,11 @@ import IntegrationStateRow from './components/integrations/IntegrationStateRow.s
|
||||
import EmployeeRefEditor from './components/typeEditors/EmployeeRefEditor.svelte'
|
||||
import setting from './plugin'
|
||||
import { filterDescendants, getOwnerFirstName, getOwnerLastName, getOwnerPosition, getValue } from './utils'
|
||||
import { hasRoleCapabilityAsync } from './hasRoleCapabilityAsync'
|
||||
|
||||
export * from './store'
|
||||
export { hasRoleCapability, getRolesForCapability } from './roleCapability'
|
||||
export { hasRoleCapabilityAsync } from './hasRoleCapabilityAsync'
|
||||
export {
|
||||
ClassAttributes,
|
||||
ClassAttributesList,
|
||||
@@ -161,12 +165,14 @@ export default async (): Promise<Resources> => ({
|
||||
OfficeSettings,
|
||||
AddSocialId,
|
||||
AddEmailSocialId,
|
||||
EmployeeRefEditor
|
||||
EmployeeRefEditor,
|
||||
UserRoleSelect
|
||||
},
|
||||
actionImpl: {
|
||||
DeleteMixin
|
||||
},
|
||||
function: {
|
||||
HasRoleCapability: hasRoleCapabilityAsync,
|
||||
GetOwnerFirstName: getOwnerFirstName,
|
||||
GetOwnerLastName: getOwnerLastName,
|
||||
GetOwnerPosition: getOwnerPosition,
|
||||
|
||||
@@ -157,6 +157,8 @@ export default mergeIds(settingId, setting, {
|
||||
PasswordAgingRule: '' as IntlString,
|
||||
PasswordAgingRuleDescription: '' as IntlString,
|
||||
ShowInTitle: '' as IntlString,
|
||||
SpaceMembersOnly: '' as IntlString
|
||||
SpaceMembersOnly: '' as IntlString,
|
||||
LastOwnerLeaveTitle: '' as IntlString,
|
||||
LastOwnerLeaveMessage: '' as IntlString
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// Copyright © 2026 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 { Account } from '@hcengineering/core'
|
||||
import { AccountRole, hasAccountRole } from '@hcengineering/core'
|
||||
import { RoleCapability, type RoleCapabilityId } from '@hcengineering/setting'
|
||||
|
||||
/** Default roles that have each capability when no RoleCapabilitySettings is set */
|
||||
const DEFAULT_ROLES_BY_CAPABILITY: Record<string, AccountRole[]> = {
|
||||
[RoleCapability.GenerateInviteLink]: [AccountRole.User, AccountRole.Maintainer, AccountRole.Owner],
|
||||
[RoleCapability.ManageInviteSettings]: [AccountRole.Maintainer, AccountRole.Owner]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given account has the specified capability.
|
||||
* Uses roleByCapability if provided; otherwise for GenerateInviteLink falls back to inviteLinkGeneratorRoles; otherwise uses built-in defaults.
|
||||
* @public
|
||||
*/
|
||||
export function hasRoleCapability (
|
||||
account: Account,
|
||||
capabilityId: RoleCapabilityId | string,
|
||||
roleByCapability: Record<string, AccountRole[]> | undefined | null,
|
||||
inviteLinkGeneratorRoles?: AccountRole[] | undefined | null
|
||||
): boolean {
|
||||
const roles = getRolesForCapability(capabilityId, roleByCapability, inviteLinkGeneratorRoles)
|
||||
return roles.some((role) => hasAccountRole(account, role))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the list of AccountRoles that have the given capability (for the current config/fallbacks).
|
||||
* @public
|
||||
*/
|
||||
export function getRolesForCapability (
|
||||
capabilityId: RoleCapabilityId | string,
|
||||
roleByCapability: Record<string, AccountRole[]> | undefined | null,
|
||||
inviteLinkGeneratorRoles?: AccountRole[] | undefined | null
|
||||
): AccountRole[] {
|
||||
if (roleByCapability?.[capabilityId] != null && roleByCapability[capabilityId].length > 0) {
|
||||
return roleByCapability[capabilityId]
|
||||
}
|
||||
if (
|
||||
capabilityId === RoleCapability.GenerateInviteLink &&
|
||||
inviteLinkGeneratorRoles != null &&
|
||||
inviteLinkGeneratorRoles.length > 0
|
||||
) {
|
||||
return inviteLinkGeneratorRoles
|
||||
}
|
||||
return DEFAULT_ROLES_BY_CAPABILITY[capabilityId] ?? []
|
||||
}
|
||||
@@ -123,6 +123,33 @@ export interface InviteSettings extends Configuration {
|
||||
expirationTime: number
|
||||
emailMask: string
|
||||
limit: number
|
||||
defaultInviteRole: AccountRole
|
||||
inviteLinkGeneratorRoles: AccountRole[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable capability IDs for role-based permissions.
|
||||
* Used with RoleCapabilitySettings to define which AccountRoles can perform which actions.
|
||||
* @public
|
||||
*/
|
||||
export const RoleCapability = {
|
||||
GenerateInviteLink: 'setting:capability:GenerateInviteLink',
|
||||
ManageInviteSettings: 'setting:capability:ManageInviteSettings'
|
||||
} as const
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type RoleCapabilityId = (typeof RoleCapability)[keyof typeof RoleCapability]
|
||||
|
||||
/**
|
||||
* Workspace-level config: which AccountRoles have which capabilities.
|
||||
* Maps capability ID -> list of roles that have it (account has capability if hasAccountRole(account, role) for any role in the list).
|
||||
* @public
|
||||
*/
|
||||
export interface RoleCapabilitySettings extends Configuration {
|
||||
/** capabilityId -> roles that are allowed (e.g. [User, Maintainer, Owner]) */
|
||||
roleByCapability: Record<string, AccountRole[]>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +191,7 @@ export default plugin(settingId, {
|
||||
General: '' as Ref<Doc>,
|
||||
Owners: '' as Ref<Doc>,
|
||||
InviteSettings: '' as Ref<Doc>,
|
||||
RoleCapabilitySettings: '' as Ref<Doc>,
|
||||
WorkspaceSetting: '' as Ref<Doc>,
|
||||
ManageSpaces: '' as Ref<Doc>,
|
||||
Spaces: '' as Ref<Doc>,
|
||||
@@ -185,6 +213,7 @@ export default plugin(settingId, {
|
||||
Integration: '' as Ref<Class<Integration>>,
|
||||
IntegrationType: '' as Ref<Class<IntegrationType>>,
|
||||
InviteSettings: '' as Ref<Class<InviteSettings>>,
|
||||
RoleCapabilitySettings: '' as Ref<Class<RoleCapabilitySettings>>,
|
||||
OfficeSettings: '' as Ref<Class<OfficeSettings>>,
|
||||
WorkspaceSetting: '' as Ref<Class<WorkspaceSetting>>
|
||||
},
|
||||
@@ -214,7 +243,8 @@ export default plugin(settingId, {
|
||||
EditRelation: '' as AnyComponent,
|
||||
Mailboxes: '' as AnyComponent,
|
||||
AddEmailSocialId: '' as AnyComponent,
|
||||
OfficeSettings: '' as AnyComponent
|
||||
OfficeSettings: '' as AnyComponent,
|
||||
UserRoleSelect: '' as AnyComponent
|
||||
},
|
||||
string: {
|
||||
Settings: '' as IntlString,
|
||||
@@ -246,6 +276,9 @@ export default plugin(settingId, {
|
||||
Owners: '' as IntlString,
|
||||
Configure: '' as IntlString,
|
||||
InviteSettings: '' as IntlString,
|
||||
RoleCapabilitySettings: '' as IntlString,
|
||||
DefaultInviteRoleForJoin: '' as IntlString,
|
||||
InviteLinkGeneratorRoles: '' as IntlString,
|
||||
General: '' as IntlString,
|
||||
Properties: '' as IntlString,
|
||||
TaskTypes: '' as IntlString,
|
||||
@@ -336,5 +369,8 @@ export default plugin(settingId, {
|
||||
},
|
||||
metadata: {
|
||||
BackupUrl: '' as Metadata<string>
|
||||
},
|
||||
function: {
|
||||
HasRoleCapability: '' as Resource<(capabilityId: RoleCapabilityId | string) => Promise<boolean>>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
export let value: string[] = []
|
||||
export let type: ArrOf<string>
|
||||
export let onChange: (value: string[]) => void
|
||||
export let readonly: boolean = false
|
||||
|
||||
export let kind: ButtonKind = 'link'
|
||||
export let size: ButtonSize = 'large'
|
||||
@@ -55,6 +56,7 @@
|
||||
{size}
|
||||
width={'100%'}
|
||||
multiselect
|
||||
disabled={readonly}
|
||||
autoSelect={false}
|
||||
on:selected={(e) => {
|
||||
onChange(e.detail)
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
hasResource,
|
||||
isDisabled
|
||||
} from '@hcengineering/presentation'
|
||||
import setting, { settingId, SettingsCategory } from '@hcengineering/setting'
|
||||
import setting, { RoleCapability, settingId, SettingsCategory } from '@hcengineering/setting'
|
||||
import {
|
||||
Action,
|
||||
closePopup,
|
||||
@@ -42,9 +42,11 @@
|
||||
import HelpAndSupport from './HelpAndSupport.svelte'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { allowGuestSignUpStore } from '@hcengineering/view-resources'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { getMetadata, getResource } from '@hcengineering/platform'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let items: SettingsCategory[] = []
|
||||
let canGenerateInviteLink = false
|
||||
|
||||
const account = getCurrentAccount()
|
||||
const settingsQuery = createQuery()
|
||||
@@ -57,6 +59,16 @@
|
||||
{ sort: { order: 1 } }
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
void getResource(setting.function.HasRoleCapability).then((checkCapability) => {
|
||||
if (checkCapability != null) {
|
||||
void checkCapability(RoleCapability.GenerateInviteLink).then((v: boolean) => {
|
||||
canGenerateInviteLink = v
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
$: person = $myEmployeeStore
|
||||
|
||||
function selectCategory (sp?: SettingsCategory): void {
|
||||
@@ -131,7 +143,7 @@
|
||||
})
|
||||
}
|
||||
actions.push(...getMenu(items, ['main']))
|
||||
if (hasAccountRole(account, AccountRole.User) && !isDisabled('invites')) {
|
||||
if (hasAccountRole(account, AccountRole.User) && !isDisabled('invites') && canGenerateInviteLink) {
|
||||
actions.push({
|
||||
icon: setting.icon.InviteWorkspace,
|
||||
label: setting.string.InviteWorkspace,
|
||||
|
||||
@@ -20,6 +20,7 @@ import core, {
|
||||
Class,
|
||||
Data,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
fillDefaults,
|
||||
findProperty,
|
||||
generateId,
|
||||
@@ -169,9 +170,9 @@ export function FieldChangedCheck (
|
||||
const process = control.client.getModel().findObject(execution.process)
|
||||
if (process === undefined) return false
|
||||
if (context.operations === undefined) return false
|
||||
const changedFields = Object.keys(context.operations)
|
||||
const operations = context.operations as DocumentUpdate<Doc>
|
||||
const target = Object.keys(params)[0]
|
||||
if (!changedFields.includes(target)) return false
|
||||
if (!TxProcessor.hasUpdate(operations, target)) return false
|
||||
const res = matchQuery([context.card], params, process.masterTag, control.client.getHierarchy(), true)
|
||||
return res.length > 0
|
||||
}
|
||||
@@ -322,11 +323,20 @@ export async function AddTag (
|
||||
res.push(tx)
|
||||
const card = control.cache.get(execution.card)
|
||||
if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card })
|
||||
if (control.client.getHierarchy().hasMixin(card, tagId)) {
|
||||
return { txes: res, rollback: [], context: null }
|
||||
}
|
||||
const cardWithMixin =
|
||||
card !== undefined ? TxProcessor.updateMixin4Doc(control.client.getHierarchy().clone(card), tx) : undefined
|
||||
if (control.client.getHierarchy().hasMixin(card, tagId)) {
|
||||
return {
|
||||
txes: res,
|
||||
rollback: [],
|
||||
context: [
|
||||
{
|
||||
_id: execution.card,
|
||||
value: cardWithMixin
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const rollback: Tx[] = [
|
||||
control.client.txFactory.createTxUpdateDoc(_process.masterTag, execution.space, execution.card, {
|
||||
@@ -398,9 +408,6 @@ export async function RunSubProcess (
|
||||
const res: Tx[] = []
|
||||
const resultContext: SuccessExecutionContext[] = []
|
||||
const rollback: Tx[] = []
|
||||
const initTransition = control.client
|
||||
.getModel()
|
||||
.findAllSync(process.class.Transition, { process: target._id, from: null })[0]
|
||||
for (const _card of Array.isArray(card) ? card : [card]) {
|
||||
if (target.parallelExecutionForbidden === true) {
|
||||
const currentExecution = await control.client.findAll(process.class.Execution, {
|
||||
@@ -414,12 +421,6 @@ export async function RunSubProcess (
|
||||
}
|
||||
}
|
||||
|
||||
// check card is exists
|
||||
const exists = await control.client.findOne(cardPlugin.class.Card, { _id: _card })
|
||||
if (exists === undefined) {
|
||||
throw processError(process.error.ObjectNotFound, { _id: _card })
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const context = params.context ?? ({} as ExecutionContext)
|
||||
const _id = generateId<Execution>()
|
||||
@@ -428,7 +429,7 @@ export async function RunSubProcess (
|
||||
execution.space,
|
||||
{
|
||||
process: processId,
|
||||
currentState: initTransition.to,
|
||||
currentState: null as any,
|
||||
card: _card,
|
||||
context,
|
||||
status: ExecutionStatus.Active,
|
||||
@@ -455,7 +456,9 @@ export async function RequestApproval (
|
||||
control: ProcessControl,
|
||||
results: UserResult[] | undefined
|
||||
): Promise<ExecuteResult> {
|
||||
if (params.user === undefined) throw processError(process.error.RequiredParamsNotProvided, { params: 'user' })
|
||||
if (params.user === undefined || params.user.length === 0) {
|
||||
throw processError(process.error.RequiredParamsNotProvided, { params: 'user' })
|
||||
}
|
||||
const group = generateId()
|
||||
const res: TxCreateDoc<ApproveRequest>[] = []
|
||||
const rollback: Tx[] = []
|
||||
@@ -481,7 +484,9 @@ export async function RequestApproval (
|
||||
rank: '',
|
||||
withRollback: params.withRollback ?? false,
|
||||
results,
|
||||
group
|
||||
group,
|
||||
actionType: params.actionType,
|
||||
field: (params as any).field
|
||||
},
|
||||
id
|
||||
)
|
||||
@@ -551,11 +556,11 @@ export async function LockSection (
|
||||
const rollback: Tx[] = []
|
||||
const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card })
|
||||
if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card })
|
||||
const readonlySections = card.readonlySections ?? []
|
||||
const readonlySections = new Set(card.readonlySections ?? [])
|
||||
const target = params._id as Ref<MasterTag>
|
||||
readonlySections.push(target)
|
||||
readonlySections.add(target)
|
||||
const tx = control.client.txFactory.createTxUpdateDoc(cardPlugin.class.Card, execution.space, execution.card, {
|
||||
readonlySections
|
||||
readonlySections: [...readonlySections]
|
||||
})
|
||||
res.push(tx)
|
||||
rollback.push(
|
||||
@@ -596,8 +601,10 @@ export async function UnlockSection (
|
||||
const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card })
|
||||
if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card })
|
||||
const target = params._id as Ref<MasterTag>
|
||||
const readonlySections = new Set(card.readonlySections ?? [])
|
||||
readonlySections.delete(target)
|
||||
const tx = control.client.txFactory.createTxUpdateDoc(cardPlugin.class.Card, execution.space, execution.card, {
|
||||
$pull: { readonlySections: target }
|
||||
readonlySections: [...readonlySections]
|
||||
})
|
||||
res.push(tx)
|
||||
rollback.push(
|
||||
@@ -608,6 +615,69 @@ export async function UnlockSection (
|
||||
return { txes: res, rollback, context: [] }
|
||||
}
|
||||
|
||||
export async function LockField (
|
||||
params: Record<string, any>,
|
||||
execution: Execution,
|
||||
control: ProcessControl
|
||||
): Promise<ExecuteResult> {
|
||||
if (params.value === undefined) throw processError(process.error.RequiredParamsNotProvided, { params: 'value' })
|
||||
const res: Tx[] = []
|
||||
const rollback: Tx[] = []
|
||||
const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card })
|
||||
if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card })
|
||||
const oldReadonlyFields = card.readonlyFields ?? []
|
||||
const readonlyFields = [...oldReadonlyFields]
|
||||
const targets = Array.isArray(params.value) ? params.value : [params.value]
|
||||
let changed = false
|
||||
for (const target of targets) {
|
||||
if (!readonlyFields.includes(target)) {
|
||||
readonlyFields.push(target)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return { txes: [], rollback: [], context: [] }
|
||||
}
|
||||
const tx = control.client.txFactory.createTxUpdateDoc(cardPlugin.class.Card, execution.space, execution.card, {
|
||||
readonlyFields
|
||||
})
|
||||
res.push(tx)
|
||||
rollback.push(
|
||||
control.client.txFactory.createTxUpdateDoc(cardPlugin.class.Card, execution.space, execution.card, {
|
||||
readonlyFields: oldReadonlyFields
|
||||
})
|
||||
)
|
||||
return { txes: res, rollback, context: [] }
|
||||
}
|
||||
|
||||
export async function UnlockField (
|
||||
params: Record<string, any>,
|
||||
execution: Execution,
|
||||
control: ProcessControl
|
||||
): Promise<ExecuteResult> {
|
||||
if (params.value === undefined) throw processError(process.error.RequiredParamsNotProvided, { params: 'value' })
|
||||
const res: Tx[] = []
|
||||
const rollback: Tx[] = []
|
||||
const card = await control.client.findOne(cardPlugin.class.Card, { _id: execution.card })
|
||||
if (card === undefined) throw processError(process.error.ObjectNotFound, { _id: execution.card })
|
||||
const oldReadonlyFields = card.readonlyFields ?? []
|
||||
const targets = Array.isArray(params.value) ? params.value : [params.value]
|
||||
const readonlyFields = oldReadonlyFields.filter((f: string) => !targets.includes(f))
|
||||
if (readonlyFields.length === oldReadonlyFields.length) {
|
||||
return { txes: [], rollback: [], context: [] }
|
||||
}
|
||||
const tx = control.client.txFactory.createTxUpdateDoc(cardPlugin.class.Card, execution.space, execution.card, {
|
||||
readonlyFields
|
||||
})
|
||||
res.push(tx)
|
||||
rollback.push(
|
||||
control.client.txFactory.createTxUpdateDoc(cardPlugin.class.Card, execution.space, execution.card, {
|
||||
readonlyFields: oldReadonlyFields
|
||||
})
|
||||
)
|
||||
return { txes: res, rollback, context: [] }
|
||||
}
|
||||
|
||||
export async function CreateToDo (
|
||||
params: MethodParams<ProcessToDo>,
|
||||
execution: Execution,
|
||||
@@ -642,7 +712,8 @@ export async function CreateToDo (
|
||||
doneOn: null,
|
||||
rank: '',
|
||||
withRollback: params.withRollback ?? false,
|
||||
results
|
||||
results,
|
||||
field: (params as any).field
|
||||
},
|
||||
id
|
||||
)
|
||||
@@ -736,7 +807,7 @@ export async function CreateCard (
|
||||
...attrs
|
||||
} as any
|
||||
if (newContent !== undefined) {
|
||||
data.content = content
|
||||
data.content = newContent
|
||||
}
|
||||
const filledData = fillDefaults(control.client.getHierarchy(), data, masterTag)
|
||||
|
||||
@@ -793,7 +864,7 @@ async function createExecution (
|
||||
execution.space,
|
||||
{
|
||||
process: proc,
|
||||
currentState: initTransition.to,
|
||||
currentState: null as any,
|
||||
card: _id,
|
||||
rollback: [],
|
||||
context: {},
|
||||
|
||||
@@ -17,6 +17,8 @@ import cardPlugin, { Card } from '@hcengineering/card'
|
||||
import core, {
|
||||
ArrOf,
|
||||
Doc,
|
||||
DocumentUpdate,
|
||||
generateId,
|
||||
Ref,
|
||||
RefTo,
|
||||
Tx,
|
||||
@@ -110,9 +112,11 @@ import {
|
||||
LockCard,
|
||||
LockSection,
|
||||
UnlockCard,
|
||||
UnlockSection
|
||||
UnlockSection,
|
||||
LockField,
|
||||
UnlockField
|
||||
} from './functions'
|
||||
import { ToDoCancellRollback, ToDoCloseRollback } from './rollback'
|
||||
import { FieldChangedRollback, ToDoCancellRollback, ToDoCloseRollback } from './rollback'
|
||||
|
||||
async function putEventToQueue (value: Omit<ProcessMessage, 'account'>, control: TriggerControl): Promise<void> {
|
||||
if (control.queue === undefined) return
|
||||
@@ -155,6 +159,7 @@ export async function OnProcessToDoClose (txes: Tx[], control: TriggerControl):
|
||||
event: events,
|
||||
execution: todo.execution,
|
||||
createdOn: tx.modifiedOn,
|
||||
_id: tx._id,
|
||||
context: {
|
||||
todo
|
||||
}
|
||||
@@ -192,6 +197,7 @@ export async function OnCustomEvent (txes: Tx[], control: TriggerControl): Promi
|
||||
execution: customEvent.execution,
|
||||
createdOn: tx.modifiedOn,
|
||||
card: customEvent.card,
|
||||
_id: tx._id,
|
||||
context: {
|
||||
eventType: customEvent.eventType,
|
||||
card
|
||||
@@ -214,6 +220,7 @@ export async function OnExecutionCreate (txes: Tx[], control: TriggerControl): P
|
||||
event: [process.trigger.OnExecutionStart],
|
||||
execution: execution._id,
|
||||
createdOn: tx.modifiedOn,
|
||||
_id: tx._id,
|
||||
context: {}
|
||||
},
|
||||
control
|
||||
@@ -227,6 +234,7 @@ export async function OnProcessToDoRemove (txes: Tx[], control: TriggerControl):
|
||||
if (tx._class !== core.class.TxRemoveDoc) continue
|
||||
const removeTx = tx as TxRemoveDoc<ProcessToDo>
|
||||
if (!control.hierarchy.isDerived(removeTx.objectClass, process.class.ProcessToDo)) continue
|
||||
if (removeTx.space === core.space.DerivedTx) continue
|
||||
const removedTodo = control.removedMap.get(removeTx.objectId) as ProcessToDo
|
||||
if (removedTodo === undefined) continue
|
||||
await putEventToQueue(
|
||||
@@ -234,6 +242,7 @@ export async function OnProcessToDoRemove (txes: Tx[], control: TriggerControl):
|
||||
event: [process.trigger.OnToDoRemove],
|
||||
execution: removedTodo.execution,
|
||||
createdOn: tx.modifiedOn,
|
||||
_id: tx._id,
|
||||
context: {
|
||||
todo: removedTodo
|
||||
}
|
||||
@@ -263,6 +272,7 @@ export async function OnExecutionContinue (txes: Tx[], control: TriggerControl):
|
||||
event: [process.trigger.OnExecutionContinue],
|
||||
execution: execution._id,
|
||||
createdOn: tx.modifiedOn,
|
||||
_id: tx._id,
|
||||
context: {}
|
||||
},
|
||||
control
|
||||
@@ -346,6 +356,83 @@ async function getExecutionReassignTxes (card: Card, control: TriggerControl): P
|
||||
return res
|
||||
}
|
||||
|
||||
async function reassignToDos (card: Card, ops: DocumentUpdate<Card>, control: TriggerControl): Promise<Tx[]> {
|
||||
const res: Tx[] = []
|
||||
const todos = await control.findAll(control.ctx, process.class.ProcessToDo, {
|
||||
attachedTo: card._id,
|
||||
doneOn: null,
|
||||
field: { $ne: null }
|
||||
} as any)
|
||||
const handledGroups = new Set<string>()
|
||||
for (const todo of todos as any[]) {
|
||||
if (todo.field === undefined || !TxProcessor.hasUpdate(ops, todo.field)) continue
|
||||
|
||||
if (todo._class === process.class.ApproveRequest) {
|
||||
const request = todo as ApproveRequest
|
||||
if (handledGroups.has(request.group)) continue
|
||||
handledGroups.add(request.group)
|
||||
|
||||
const newUsers = (card[todo.field as keyof Card] as any[]) ?? []
|
||||
if (newUsers.length === 0) {
|
||||
continue
|
||||
}
|
||||
const currentRequests = await control.findAll(control.ctx, process.class.ApproveRequest, {
|
||||
group: request.group,
|
||||
doneOn: null
|
||||
})
|
||||
const currentUsers = currentRequests.map((r) => r.user)
|
||||
|
||||
// Remove users not in new list
|
||||
for (const req of currentRequests) {
|
||||
if (!newUsers.includes(req.user)) {
|
||||
res.push(control.txFactory.createTxRemoveDoc(req._class, req.space, req._id))
|
||||
}
|
||||
}
|
||||
|
||||
// Add users not in current list
|
||||
for (const user of newUsers) {
|
||||
if (!currentUsers.includes(user)) {
|
||||
const id = generateId<ApproveRequest>()
|
||||
const { _id, modifiedBy, modifiedOn, ...data } = request as any
|
||||
res.push(
|
||||
control.txFactory.createTxCreateDoc(
|
||||
request._class,
|
||||
request.space,
|
||||
{
|
||||
...data,
|
||||
user
|
||||
},
|
||||
id
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const newUser = card[todo.field as keyof Card]
|
||||
if (newUser === todo.user) continue
|
||||
|
||||
if (newUser != null) {
|
||||
res.push(control.txFactory.createTxRemoveDoc(todo._class, todo.space, todo._id))
|
||||
|
||||
const id = generateId<ProcessToDo>()
|
||||
const { _id, modifiedBy, modifiedOn, ...data } = todo
|
||||
res.push(
|
||||
control.txFactory.createTxCreateDoc(
|
||||
todo._class,
|
||||
todo.space,
|
||||
{
|
||||
...data,
|
||||
user: newUser
|
||||
},
|
||||
id
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function OnCardCreate (txes: Tx[], control: TriggerControl): Promise<Tx[]> {
|
||||
const res: Tx[] = []
|
||||
for (const tx of txes) {
|
||||
@@ -396,6 +483,7 @@ export async function OnCardUpdate (txes: Tx[], control: TriggerControl): Promis
|
||||
event: [process.trigger.OnCardUpdate, process.trigger.WhenFieldChanges],
|
||||
card: cudTx.objectId,
|
||||
createdOn: tx.modifiedOn,
|
||||
_id: tx._id,
|
||||
context: {
|
||||
card: card[0],
|
||||
operations: ops ?? {}
|
||||
@@ -403,6 +491,8 @@ export async function OnCardUpdate (txes: Tx[], control: TriggerControl): Promis
|
||||
},
|
||||
control
|
||||
)
|
||||
const reassignTxes = await reassignToDos(card[0], ops ?? {}, control)
|
||||
res.push(...reassignTxes)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -511,7 +601,9 @@ export default async () => ({
|
||||
LockCard,
|
||||
LockSection,
|
||||
UnlockCard,
|
||||
UnlockSection
|
||||
UnlockSection,
|
||||
LockField,
|
||||
UnlockField
|
||||
},
|
||||
transform: {
|
||||
CurrentDate,
|
||||
@@ -555,7 +647,8 @@ export default async () => ({
|
||||
},
|
||||
rollbacks: {
|
||||
ToDoCloseRollback,
|
||||
ToDoCancellRollback
|
||||
ToDoCancellRollback,
|
||||
FieldChangedRollback
|
||||
},
|
||||
trigger: {
|
||||
OnProcessRemove,
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Tx } from '@hcengineering/core'
|
||||
import { Card } from '@hcengineering/card'
|
||||
import { DocumentUpdate, Tx } from '@hcengineering/core'
|
||||
import { ProcessToDo } from '@hcengineering/process'
|
||||
import { ProcessControl } from '@hcengineering/server-process'
|
||||
|
||||
@@ -37,3 +38,70 @@ export function ToDoCancellRollback (context: Record<string, any>, control: Proc
|
||||
todo.modifiedBy
|
||||
)
|
||||
}
|
||||
|
||||
export function FieldChangedRollback (context: Record<string, any>, control: ProcessControl): Tx | undefined {
|
||||
const card = context.card as Card
|
||||
const ops = context.operations as DocumentUpdate<Card>
|
||||
if (card === undefined || ops === undefined) return
|
||||
const antiOps: DocumentUpdate<any> = invertUpdate(ops)
|
||||
return control.client.txFactory.createTxUpdateDoc(card._class, card.space, card._id, antiOps)
|
||||
}
|
||||
|
||||
function invertUpdate (ops: Record<string, any>): Record<string, any> {
|
||||
const antiOps: Record<string, any> = {}
|
||||
for (const [key, value] of Object.entries(ops)) {
|
||||
if (key.startsWith('$')) {
|
||||
const inverted = invertOperator(key, value)
|
||||
if (inverted !== undefined) {
|
||||
for (const [antiKey, antiValue] of Object.entries(inverted)) {
|
||||
antiOps[antiKey] = { ...(antiOps[antiKey] ?? {}), ...antiValue }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
antiOps.$unset = { ...(antiOps.$unset ?? {}), [key]: true }
|
||||
}
|
||||
}
|
||||
return antiOps
|
||||
}
|
||||
|
||||
function invertOperator (name: string, op: any): Record<string, any> | undefined {
|
||||
switch (name) {
|
||||
case '$inc': {
|
||||
const result: Record<string, number> = {}
|
||||
for (const [key, val] of Object.entries(op)) {
|
||||
result[key] = -(val as number)
|
||||
}
|
||||
return { $inc: result }
|
||||
}
|
||||
case '$push': {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(op)) {
|
||||
if (typeof val === 'object' && val !== null && '$each' in val) {
|
||||
result[key] = { $in: (val as any).$each }
|
||||
} else {
|
||||
result[key] = val
|
||||
}
|
||||
}
|
||||
return { $pull: result }
|
||||
}
|
||||
case '$pull': {
|
||||
const result: Record<string, any> = {}
|
||||
for (const [key, val] of Object.entries(op)) {
|
||||
if (typeof val === 'object' && val !== null && '$in' in val) {
|
||||
result[key] = { $each: (val as any).$in }
|
||||
} else {
|
||||
result[key] = { $each: [val] }
|
||||
}
|
||||
}
|
||||
return { $push: result }
|
||||
}
|
||||
case '$rename': {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [key, val] of Object.entries(op)) {
|
||||
result[val as string] = key
|
||||
}
|
||||
return { $rename: result }
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user