Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,26 @@
// limitations under the License.
-->
<script lang="ts">
import { permissionsStore } from '@hcengineering/contact-resources'
import { DocumentQuery, Ref, SortingOrder } from '@hcengineering/core'
import { IntlString, getEmbeddedLabel, translateCB } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Milestone } from '@hcengineering/tracker'
import type { ButtonKind, ButtonSize, LabelAndProps, PopupResult } from '@hcengineering/ui'
import { Button, ButtonShape, Label, SelectPopup, eventToHTMLElement, showPopup, themeStore } from '@hcengineering/ui'
import {
Button,
ButtonShape,
IconAdd,
Label,
SelectPopup,
eventToHTMLElement,
showPopup,
themeStore
} from '@hcengineering/ui'
import tracker from '../../plugin'
import { milestoneStatusAssets } from '../../types'
import { canCreateMilestone } from './milestonePermissions'
import NewMilestone from './NewMilestone.svelte'

export let value: Ref<Milestone> | null | undefined
export let space: DocumentQuery<Milestone>['space'] | undefined = undefined
Expand Down Expand Up @@ -76,14 +88,20 @@
selectedMilestone = milestones.find((it) => it._id === newMilestoneId)
}

const getMilestoneInfo = (rawMilestones: Milestone[], sp: Milestone | undefined) => {
const createMilestoneId = '#createMilestone'
// A new milestone can be created only when the selector is bound to a single project
// and the user may create milestones in it
$: canCreate = canCreateMilestone(space, isEditable, isAction, $permissionsStore)

const getMilestoneInfo = (rawMilestones: Milestone[], sp: Milestone | undefined, canCreate: boolean) => {
return [
{
id: null,
icon: tracker.icon.Milestone,
label: tracker.string.NoMilestone,
isSelected: sp === undefined
},
...(canCreate ? [{ id: createMilestoneId, icon: IconAdd, label: tracker.string.NewMilestone }] : []),
...rawMilestones.map((p) => ({
id: p._id,
icon: tracker.icon.Milestone,
Expand All @@ -94,7 +112,22 @@
]
}

$: milestones = getMilestoneInfo(rawMilestones, selectedMilestone)
$: milestones = getMilestoneInfo(rawMilestones, selectedMilestone, canCreate)

const client = getClient()

function createMilestone (): void {
if (!canCreate || typeof space !== 'string') return
const project = space
showPopup(NewMilestone, { space: project }, 'top', async (milestoneId: Ref<Milestone> | undefined) => {
if (milestoneId == null) return
// The project can be changed inside the dialog, select the milestone only if it belongs to our project
const milestone = await client.findOne(tracker.class.Milestone, { _id: milestoneId })
if (milestone?.space === project) {
onChange?.(milestoneId)
}
})
}

let milestonePopup: PopupResult | undefined
const handleMilestoneEditorOpened = async (event: MouseEvent): Promise<void> => {
Expand All @@ -108,8 +141,12 @@
{ value: milestones, placeholder: popupPlaceholder, searchable: true },
eventToHTMLElement(event),
(evt) => {
onChange?.(evt)
milestonePopup = undefined
if (evt === createMilestoneId) {
createMilestone()
return
}
onChange?.(evt)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { permissionsStore } from '@hcengineering/contact-resources'
import { Data, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { Card, getClient, SpaceSelector } from '@hcengineering/presentation'
Expand All @@ -23,6 +24,7 @@
import tracker from '../../plugin'
import MilestoneStatusEditor from './MilestoneStatusEditor.svelte'
import ProjectPresenter from '../projects/ProjectPresenter.svelte'
import { canSaveMilestone } from './milestonePermissions'

export let space: Ref<Project>
const dispatch = createEventDispatcher()
Expand All @@ -38,18 +40,23 @@
targetDate: Date.now() + 14 * 24 * 60 * 60 * 1000
}

let createdMilestone: Ref<Milestone> | undefined

$: canSave = canSaveMilestone(space, object.label, $permissionsStore)

async function onSave () {
await client.createDoc(tracker.class.Milestone, space, object)
if (!canSaveMilestone(space, object.label, $permissionsStore)) return
createdMilestone = await client.createDoc(tracker.class.Milestone, space, object)
}
</script>

<Card
label={tracker.string.NewMilestone}
okAction={onSave}
canSave={object.label !== ''}
{canSave}
okLabel={tracker.string.CreateMilestone}
gap={'gapV-4'}
on:close={() => dispatch('close')}
on:close={() => dispatch('close', createdMilestone)}
on:changeContent
>
<svelte:fragment slot="header">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//

import type { PermissionsStore } from '@hcengineering/contact'
import type { Permission, Ref } from '@hcengineering/core'
import type { Project } from '@hcengineering/tracker'
import { canCreateMilestone, canSaveMilestone } from '../milestonePermissions'

jest.mock('@hcengineering/view-resources', () => jest.requireActual('../../../../../view-resources/src/permissions'))
jest.mock('@hcengineering/platform', () => ({
...jest.requireActual('@hcengineering/platform'),
getMetadata: jest.fn()
}))
jest.mock('@hcengineering/presentation', () => ({
getClient: () => ({
getHierarchy: () => ({ getAncestors: (objectClass: string) => [objectClass] }),
getModel: () => ({
findAllSync: (_class: string, query: { txClass: string }) =>
query.txClass === 'core:class:TxCreateDoc'
? [
{
_id: 'allow-milestone',
objectClass: 'tracker:class:Milestone'
},
{
_id: 'forbid-milestone',
objectClass: 'tracker:class:Milestone',
forbid: true
}
]
: []
})
})
}))
jest.mock('../../../plugin', () => ({
__esModule: true,
default: { class: { Milestone: 'tracker:class:Milestone' } }
}))

const project = 'project' as Ref<Project>
const otherProject = 'other-project' as Ref<Project>

function permissions (permission?: string, restricted: boolean = false): PermissionsStore {
return {
ps: permission === undefined ? {} : { [project]: new Set([permission as Ref<Permission>]) },
ap: {},
ms: {},
whitelist: new Set(),
restrictedSpaces: new Set(restricted ? [project] : [])
}
}

describe('milestone creation permissions', () => {
it('offers creation to a permitted user in a restricted project', () => {
expect(canCreateMilestone(project, true, false, permissions('allow-milestone', true))).toBe(true)
})

it('hides creation from a forbidden user even in an unrestricted project', () => {
expect(canCreateMilestone(project, true, false, permissions('forbid-milestone'))).toBe(false)
})

it('hides creation in a restricted project without a creation grant', () => {
const store = permissions(undefined, true)
store.ps[project] = new Set()
expect(canCreateMilestone(project, true, false, store)).toBe(false)
})

it('appears after permissions load and disappears when permission is revoked', () => {
expect(canCreateMilestone(project, true, false, permissions())).toBe(false)
expect(canCreateMilestone(project, true, false, permissions('allow-milestone', true))).toBe(true)
expect(canCreateMilestone(project, true, false, permissions('forbid-milestone', true))).toBe(false)
})

it('does not carry permission over to another project', () => {
expect(canCreateMilestone(otherProject, true, false, permissions('allow-milestone'))).toBe(false)
})

it('allows creation in a whitelisted project', () => {
const store = permissions()
store.whitelist.add(project)
expect(canCreateMilestone(project, true, false, store)).toBe(true)
})

it('requires an editable selector bound to a single project outside action mode', () => {
const store = permissions('allow-milestone')
expect(canCreateMilestone(project, false, false, store)).toBe(false)
expect(canCreateMilestone(project, true, true, store)).toBe(false)
expect(canCreateMilestone(undefined, true, false, store)).toBe(false)
expect(canCreateMilestone({ $in: [project, otherProject] }, true, false, store)).toBe(false)
})
})

describe('milestone dialog save permissions', () => {
it('requires a label even when the user may create milestones', () => {
const store = permissions('allow-milestone', true)
expect(canSaveMilestone(project, '', store)).toBe(false)
expect(canSaveMilestone(project, 'Release', store)).toBe(true)
})

it('disables saving when the dialog switches to a forbidden project and enables it on return', () => {
const store = permissions('allow-milestone', true)
store.ps[otherProject] = new Set(['forbid-milestone' as Ref<Permission>])
expect(canSaveMilestone(project, 'Release', store)).toBe(true)
expect(canSaveMilestone(otherProject, 'Release', store)).toBe(false)
expect(canSaveMilestone(project, 'Release', store)).toBe(true)
})

it('waits for permissions and disables saving when permission is revoked', () => {
expect(canSaveMilestone(project, 'Release', permissions())).toBe(false)
expect(canSaveMilestone(project, 'Release', permissions('allow-milestone', true))).toBe(true)
expect(canSaveMilestone(project, 'Release', permissions('forbid-milestone', true))).toBe(false)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
//
// Copyright © 2026 Hardcore Engineering Inc.
// SPDX-License-Identifier: EPL-2.0
//

import type { PermissionsStore } from '@hcengineering/contact'
import type { DocumentQuery } from '@hcengineering/core'
import type { Milestone } from '@hcengineering/tracker'
import { canCreateObject } from '@hcengineering/view-resources'
import tracker from '../../plugin'

export function canCreateMilestone (
space: DocumentQuery<Milestone>['space'] | undefined,
isEditable: boolean,
isAction: boolean,
permissions: PermissionsStore
): boolean {
if (!isEditable || isAction || typeof space !== 'string') return false
// The permissions query loads asynchronously. Do not allow creation before this project is known.
if (!permissions.whitelist.has(space) && permissions.ps[space] === undefined) return false
return canCreateObject(tracker.class.Milestone, space, permissions)
}

export function canSaveMilestone (
space: DocumentQuery<Milestone>['space'] | undefined,
label: string,
permissions: PermissionsStore
): boolean {
return label !== '' && canCreateMilestone(space, true, false, permissions)
}