mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+3
@@ -40499,6 +40499,9 @@ importers:
|
||||
'@typescript-eslint/parser':
|
||||
specifier: ^6.21.0
|
||||
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
cross-env:
|
||||
specifier: ~7.0.3
|
||||
version: 7.0.3
|
||||
esbuild:
|
||||
specifier: ^0.25.10
|
||||
version: 0.25.12
|
||||
|
||||
@@ -21,7 +21,9 @@ const mockApp = {
|
||||
}
|
||||
return `/mock/${name}`
|
||||
}),
|
||||
getName: jest.fn(() => 'TestApp')
|
||||
getName: jest.fn(() => 'TestApp'),
|
||||
isPackaged: true,
|
||||
getAppPath: jest.fn(() => '/mock/appPath')
|
||||
}
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//
|
||||
// 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 { WebContents } from 'electron'
|
||||
import { IpcMessage } from '../../ui/ipcMessages'
|
||||
|
||||
type IpcHandlerFn = (...args: any[]) => any
|
||||
|
||||
/** Captured ipcMain.handle / ipcMain.on callbacks for assertions. */
|
||||
const ipcHandlers = new Map<string, IpcHandlerFn>()
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn((channel: string, handler: (...args: any[]) => any) => {
|
||||
ipcHandlers.set(channel, handler)
|
||||
}),
|
||||
on: jest.fn((channel: string, handler: (...args: any[]) => any) => {
|
||||
ipcHandlers.set(`on:${channel}`, handler)
|
||||
})
|
||||
},
|
||||
BrowserWindow: {
|
||||
fromWebContents: jest.fn()
|
||||
},
|
||||
BrowserView: jest.fn()
|
||||
}))
|
||||
|
||||
function makeWebContents (partial: {
|
||||
id: number
|
||||
destroyed?: boolean
|
||||
findInPage?: jest.Mock
|
||||
stopFindInPage?: jest.Mock
|
||||
send?: jest.Mock
|
||||
}): WebContents {
|
||||
return {
|
||||
id: partial.id,
|
||||
isDestroyed: () => partial.destroyed ?? false,
|
||||
findInPage: partial.findInPage ?? jest.fn(),
|
||||
stopFindInPage: partial.stopFindInPage ?? jest.fn(),
|
||||
send: partial.send ?? jest.fn()
|
||||
} as unknown as WebContents
|
||||
}
|
||||
|
||||
describe('findInPage main IPC', () => {
|
||||
let originalConsoleError: typeof console.error
|
||||
let registerFindInPageIpcHandlers: () => void
|
||||
let registerFindInPageTarget: (overlayWc: WebContents, pageWc: WebContents) => void
|
||||
|
||||
beforeEach(async () => {
|
||||
ipcHandlers.clear()
|
||||
jest.resetModules()
|
||||
originalConsoleError = console.error
|
||||
console.error = jest.fn()
|
||||
const m = await import('../../main/findInPage')
|
||||
registerFindInPageIpcHandlers = m.registerFindInPageIpcHandlers
|
||||
registerFindInPageTarget = m.registerFindInPageTarget
|
||||
registerFindInPageIpcHandlers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
test('FindInPage clears selection and returns -1 for empty text', async () => {
|
||||
const stopFindInPage = jest.fn()
|
||||
const findInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 10, stopFindInPage, findInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
expect(handler).toBeDefined()
|
||||
const result = await handler?.({ sender }, '', {})
|
||||
expect(result).toBe(-1)
|
||||
expect(stopFindInPage).toHaveBeenCalledWith('clearSelection')
|
||||
expect(findInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('FindInPage runs on page webContents when overlay is registered as invoker', async () => {
|
||||
const pageFindInPage = jest.fn().mockResolvedValue(7)
|
||||
const pageStop = jest.fn()
|
||||
const pageWc = makeWebContents({ id: 1, findInPage: pageFindInPage, stopFindInPage: pageStop })
|
||||
const overlayWc = makeWebContents({ id: 2 })
|
||||
registerFindInPageTarget(overlayWc, pageWc)
|
||||
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
const result = await handler?.({ sender: overlayWc }, 'needle', { forward: true, findNext: false })
|
||||
expect(result).toBe(7)
|
||||
expect(pageFindInPage).toHaveBeenCalledWith('needle', { forward: true, findNext: false })
|
||||
})
|
||||
|
||||
test('FindInPage returns -1 when target webContents is destroyed', async () => {
|
||||
const findInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 20, destroyed: true, findInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
const result = await handler?.({ sender }, 'x', {})
|
||||
expect(result).toBe(-1)
|
||||
expect(findInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('FindInPage returns -1 when findInPage throws', async () => {
|
||||
const findInPage = jest.fn().mockImplementation(() => {
|
||||
throw new Error('find failed')
|
||||
})
|
||||
const sender = makeWebContents({ id: 30, findInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.FindInPage)
|
||||
const result = await handler?.({ sender }, 'x', {})
|
||||
expect(result).toBe(-1)
|
||||
})
|
||||
|
||||
test('StopFindInPage no-ops when webContents is destroyed', async () => {
|
||||
const stopFindInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 40, destroyed: true, stopFindInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
|
||||
await handler?.({ sender }, 'clearSelection')
|
||||
expect(stopFindInPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('StopFindInPage forwards to resolveFindTarget', async () => {
|
||||
const stopFindInPage = jest.fn()
|
||||
const sender = makeWebContents({ id: 50, stopFindInPage })
|
||||
const handler = ipcHandlers.get(IpcMessage.StopFindInPage)
|
||||
await handler?.({ sender }, 'keepSelection')
|
||||
expect(stopFindInPage).toHaveBeenCalledWith('keepSelection')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// 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 * as nodePath from 'path'
|
||||
import { getBundledUiDistPath, getFileInPublicBundledFolder } from '../../main/path'
|
||||
|
||||
const mockGetAppPath = jest.fn(() => '/mock/appPath')
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: (): string => mockGetAppPath()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('path (bundled UI)', () => {
|
||||
beforeEach(() => {
|
||||
mockGetAppPath.mockReturnValue('/mock/appPath')
|
||||
})
|
||||
|
||||
test('getBundledUiDistPath joins getAppPath with dist/ui', () => {
|
||||
mockGetAppPath.mockReturnValue('/Applications/Huly.app/Contents/Resources/app.asar')
|
||||
expect(getBundledUiDistPath()).toBe(
|
||||
nodePath.join('/Applications/Huly.app/Contents/Resources/app.asar', 'dist', 'ui')
|
||||
)
|
||||
})
|
||||
|
||||
test('getFileInPublicBundledFolder nests under public', () => {
|
||||
mockGetAppPath.mockReturnValue('/repo/desktop')
|
||||
expect(getFileInPublicBundledFolder('AppIcon.png')).toBe(
|
||||
nodePath.join('/repo/desktop', 'dist', 'ui', 'public', 'AppIcon.png')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import { OptionValues, program } from 'commander'
|
||||
program
|
||||
.name('Huly')
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments(true)
|
||||
.option('-s, --server <url>', 'Remote server URL (front). E.g. https://huly.app')
|
||||
|
||||
let opts: OptionValues | null = null
|
||||
|
||||
@@ -28,12 +28,23 @@ export interface PackedConfig {
|
||||
function readConfigFile (filePath: string): PackedConfig | undefined {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackedConfig
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config from ${filePath}:`, err)
|
||||
} catch (err: unknown) {
|
||||
const code = err != null && typeof err === 'object' && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined
|
||||
if (code !== 'ENOENT') {
|
||||
console.error(`Failed to read config from ${filePath}:`, err)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Packaged app: extraResources `config/config.json`. Dev: webpack `public/` → `dist/ui/public/`. */
|
||||
function getBundledResourcesConfigPath (): string {
|
||||
if (app.isPackaged) {
|
||||
return path.join(process.resourcesPath, 'config', 'config.json')
|
||||
}
|
||||
return path.join(app.getAppPath(), 'dist', 'ui', 'public', 'config', 'config.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a JSON config file, logging errors.
|
||||
*/
|
||||
@@ -55,7 +66,7 @@ function writeConfigFile (filePath: string, config: PackedConfig): boolean {
|
||||
function migrateConfigIfNeeded (): void {
|
||||
try {
|
||||
const userDataConfigPath = path.join(app.getPath('userData'), 'config.json')
|
||||
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
|
||||
const resourcesConfigPath = getBundledResourcesConfigPath()
|
||||
|
||||
const userDataDir = app.getPath('userData')
|
||||
if (!fs.existsSync(userDataDir)) {
|
||||
@@ -109,6 +120,5 @@ export function readPackedConfig (): PackedConfig | undefined {
|
||||
}
|
||||
|
||||
// Fallback to bundled config if userData config doesn't exist
|
||||
const resourcesConfigPath = path.join(process.resourcesPath, 'config', 'config.json')
|
||||
return readConfigFile(resourcesConfigPath)
|
||||
return readConfigFile(getBundledResourcesConfigPath())
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
|
||||
import { BrowserWindow } from 'electron'
|
||||
import { MenuBarAction, CommandLogout, CommandSelectWorkspace, CommandOpenSettings } from '../ui/types'
|
||||
import { OsIntegration } from './osIntegration'
|
||||
import { IpcMessage } from '../ui/ipcMessages'
|
||||
import { OsIntegration } from './osIntegration'
|
||||
import { openFindInPageBar } from './findInPage'
|
||||
|
||||
export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, action: MenuBarAction, os: OsIntegration | undefined): void {
|
||||
if (mainWindow == null) {
|
||||
@@ -67,6 +68,9 @@ export function dispatchMenuBarAction (mainWindow: BrowserWindow | undefined, ac
|
||||
case 'select-all':
|
||||
mainWindow.webContents.selectAll()
|
||||
break
|
||||
case 'find':
|
||||
openFindInPageBar(mainWindow)
|
||||
break
|
||||
case 'reload':
|
||||
mainWindow?.reload()
|
||||
break
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//
|
||||
// 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 { BrowserView, BrowserWindow, ipcMain, WebContents } from 'electron'
|
||||
import { IpcMessage } from '../ui/ipcMessages'
|
||||
|
||||
let ipcHandlersRegistered = false
|
||||
|
||||
/** Overlay invoker webContents id → page webContents to run `findInPage` on. */
|
||||
const findPageTargetByInvokerId = new Map<number, WebContents>()
|
||||
/** Page webContents id → overlay that should receive `found-in-page` IPC. */
|
||||
const findResultRecipientByPageId = new Map<number, WebContents>()
|
||||
const pagesWithFoundInPageListener = new WeakSet<WebContents>()
|
||||
|
||||
const overlayViewsByWindowId = new Map<number, BrowserView>()
|
||||
const overlayVisibleByWindowId = new Map<number, boolean>()
|
||||
|
||||
const OVERLAY_WIDTH = 392
|
||||
const OVERLAY_HEIGHT = 64
|
||||
|
||||
function resolveFindTarget (sender: WebContents): WebContents {
|
||||
return findPageTargetByInvokerId.get(sender.id) ?? sender
|
||||
}
|
||||
|
||||
function webContentsIfAlive (wc: WebContents): WebContents | null {
|
||||
return wc.isDestroyed() ? null : wc
|
||||
}
|
||||
|
||||
export function registerFindInPageTarget (overlayWc: WebContents, pageWc: WebContents): void {
|
||||
findPageTargetByInvokerId.set(overlayWc.id, pageWc)
|
||||
}
|
||||
|
||||
export function unregisterFindInPageTarget (overlayWc: WebContents): void {
|
||||
findPageTargetByInvokerId.delete(overlayWc.id)
|
||||
}
|
||||
|
||||
export function unregisterFindInPageForwarding (pageWc: WebContents): void {
|
||||
findResultRecipientByPageId.delete(pageWc.id)
|
||||
}
|
||||
|
||||
export function attachFindInPageResultForwarding (pageWc: WebContents, overlayWc: WebContents): void {
|
||||
findResultRecipientByPageId.set(pageWc.id, overlayWc)
|
||||
if (pagesWithFoundInPageListener.has(pageWc)) {
|
||||
return
|
||||
}
|
||||
pagesWithFoundInPageListener.add(pageWc)
|
||||
pageWc.on('found-in-page', (_event, result) => {
|
||||
const recipient = findResultRecipientByPageId.get(pageWc.id)
|
||||
if (recipient == null || recipient.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
recipient.send(IpcMessage.FindInPageResult, result)
|
||||
} catch (err) {
|
||||
console.error('[find] forward found-in-page failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function layoutFindOverlayBounds (win: BrowserWindow, view: BrowserView, visible: boolean): void {
|
||||
overlayVisibleByWindowId.set(win.id, visible)
|
||||
if (view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (!visible || win.isDestroyed()) {
|
||||
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
|
||||
return
|
||||
}
|
||||
const b = win.getContentBounds()
|
||||
view.setBounds({
|
||||
x: Math.max(0, b.width - OVERLAY_WIDTH - 12),
|
||||
y: 12,
|
||||
width: OVERLAY_WIDTH,
|
||||
height: OVERLAY_HEIGHT
|
||||
})
|
||||
}
|
||||
|
||||
export function registerFindOverlayView (win: BrowserWindow, view: BrowserView): void {
|
||||
overlayViewsByWindowId.set(win.id, view)
|
||||
}
|
||||
|
||||
export function unregisterFindOverlayView (win: BrowserWindow): void {
|
||||
overlayViewsByWindowId.delete(win.id)
|
||||
overlayVisibleByWindowId.delete(win.id)
|
||||
}
|
||||
|
||||
export function openFindInPageBar (win: BrowserWindow | undefined): void {
|
||||
if (win == null || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const view = overlayViewsByWindowId.get(win.id)
|
||||
if (view == null || view.webContents.isDestroyed()) {
|
||||
try {
|
||||
if (!win.webContents.isDestroyed()) {
|
||||
win.webContents.send(IpcMessage.OpenFindBar)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[find] openFindBar fallback send failed:', err)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
layoutFindOverlayBounds(win, view, true)
|
||||
view.webContents.send(IpcMessage.OpenFindBar)
|
||||
} catch (err) {
|
||||
console.error('[find] openFindBar overlay send failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function attachResizeRelayoutFindOverlay (win: BrowserWindow): void {
|
||||
const onResize = (): void => {
|
||||
if (win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
if (overlayVisibleByWindowId.get(win.id) !== true) {
|
||||
return
|
||||
}
|
||||
const view = overlayViewsByWindowId.get(win.id)
|
||||
if (view == null || view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
layoutFindOverlayBounds(win, view, true)
|
||||
}
|
||||
win.on('resize', onResize)
|
||||
}
|
||||
|
||||
export function registerFindInPageIpcHandlers (): void {
|
||||
if (ipcHandlersRegistered) {
|
||||
return
|
||||
}
|
||||
ipcHandlersRegistered = true
|
||||
|
||||
ipcMain.handle(IpcMessage.FindInPage, async (event, text: string, options?: { forward?: boolean, findNext?: boolean, matchCase?: boolean, wordStart?: boolean, medialCapitalAsWordStart?: boolean }) => {
|
||||
try {
|
||||
const wc = webContentsIfAlive(resolveFindTarget(event.sender))
|
||||
if (wc == null) {
|
||||
return -1
|
||||
}
|
||||
if (text === '') {
|
||||
wc.stopFindInPage('clearSelection')
|
||||
return -1
|
||||
}
|
||||
return await Promise.resolve(wc.findInPage(text, options ?? {}))
|
||||
} catch (err) {
|
||||
console.error('[find] findInPage handler failed:', err)
|
||||
return -1
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcMessage.StopFindInPage, async (event, action: 'clearSelection' | 'keepSelection' | 'activateSelection') => {
|
||||
try {
|
||||
const wc = webContentsIfAlive(resolveFindTarget(event.sender))
|
||||
if (wc == null) {
|
||||
return
|
||||
}
|
||||
wc.stopFindInPage(action)
|
||||
} catch (err) {
|
||||
console.error('[find] stopFindInPage handler failed:', err)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on(IpcMessage.FindOverlayLayout, (event, visible: boolean) => {
|
||||
try {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (win == null || win.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const view = overlayViewsByWindowId.get(win.id)
|
||||
if (view == null || view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
layoutFindOverlayBounds(win, view, visible)
|
||||
} catch (err) {
|
||||
console.error('[find] FindOverlayLayout handler failed:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function attachFindShortcutToWebContents (wc: WebContents, openFindBar: () => void): void {
|
||||
wc.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') {
|
||||
return
|
||||
}
|
||||
const mod = input.control || input.meta
|
||||
if (!mod || input.alt) {
|
||||
return
|
||||
}
|
||||
const key = input.key.toLowerCase()
|
||||
if (key !== 'f' || input.shift) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
openFindBar()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// 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 { BrowserView, BrowserWindow } from 'electron'
|
||||
import path from 'path'
|
||||
import {
|
||||
attachFindInPageResultForwarding,
|
||||
attachFindShortcutToWebContents,
|
||||
attachResizeRelayoutFindOverlay,
|
||||
openFindInPageBar,
|
||||
registerFindInPageTarget,
|
||||
registerFindOverlayView,
|
||||
unregisterFindInPageForwarding,
|
||||
unregisterFindInPageTarget,
|
||||
unregisterFindOverlayView
|
||||
} from './findInPage'
|
||||
import { getBundledUiDistPath } from './path'
|
||||
|
||||
function destroyBrowserViewSafe (view: BrowserView): void {
|
||||
try {
|
||||
if (view.webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
const destroy = (view.webContents as { destroy?: () => void }).destroy
|
||||
destroy?.()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the find UI in a separate BrowserView so `webContents.findInPage` on the
|
||||
* main page does not match the query typed in the find field.
|
||||
*
|
||||
* If the overlay cannot be loaded, the window still works: Cmd/Ctrl+F becomes a
|
||||
* no-op for the overlay (main page may still receive `OpenFindBar` if anything listens).
|
||||
*/
|
||||
export async function setupFindInPageOverlayForWindow (win: BrowserWindow, sessionPartition: string, preloadScriptPath: string): Promise<void> {
|
||||
const pageWc = win.webContents
|
||||
const overlayHtmlPath = path.join(getBundledUiDistPath(), 'find-in-page-overlay.html')
|
||||
|
||||
const view = new BrowserView({
|
||||
webPreferences: {
|
||||
devTools: true,
|
||||
sandbox: false,
|
||||
nodeIntegration: true,
|
||||
partition: sessionPartition,
|
||||
preload: preloadScriptPath
|
||||
}
|
||||
})
|
||||
|
||||
const openFind = (): void => {
|
||||
openFindInPageBar(win)
|
||||
}
|
||||
|
||||
try {
|
||||
await view.webContents.loadFile(overlayHtmlPath)
|
||||
} catch (err) {
|
||||
console.error('[find] Overlay failed to load; find bar disabled for this window:', overlayHtmlPath, err)
|
||||
destroyBrowserViewSafe(view)
|
||||
attachFindShortcutToWebContents(pageWc, openFind)
|
||||
return
|
||||
}
|
||||
|
||||
if (win.isDestroyed() || pageWc.isDestroyed()) {
|
||||
destroyBrowserViewSafe(view)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win.addBrowserView(view)
|
||||
view.setBounds({ x: 0, y: 0, width: 0, height: 0 })
|
||||
registerFindOverlayView(win, view)
|
||||
attachResizeRelayoutFindOverlay(win)
|
||||
registerFindInPageTarget(view.webContents, pageWc)
|
||||
attachFindInPageResultForwarding(pageWc, view.webContents)
|
||||
attachFindShortcutToWebContents(pageWc, openFind)
|
||||
attachFindShortcutToWebContents(view.webContents, openFind)
|
||||
|
||||
win.on('close', () => {
|
||||
unregisterFindOverlayView(win)
|
||||
if (!view.webContents.isDestroyed()) {
|
||||
unregisterFindInPageTarget(view.webContents)
|
||||
}
|
||||
unregisterFindInPageForwarding(pageWc)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[find] Overlay registration failed; find bar disabled for this window:', err)
|
||||
try {
|
||||
win.removeBrowserView(view)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
unregisterFindOverlayView(win)
|
||||
destroyBrowserViewSafe(view)
|
||||
attachFindShortcutToWebContents(pageWc, openFind)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,12 @@
|
||||
//
|
||||
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import * as nodePath from 'path'
|
||||
|
||||
export function getBundledUiDistPath (): string {
|
||||
return nodePath.join(app.getAppPath(), 'dist', 'ui')
|
||||
}
|
||||
|
||||
export function getFileInPublicBundledFolder (fileName: string): string {
|
||||
return path.join(app.getAppPath(), 'dist', 'ui', 'public', fileName)
|
||||
return nodePath.join(getBundledUiDistPath(), 'public', fileName)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { Menu, MenuItemConstructorOptions } from 'electron'
|
||||
import { BrowserWindow, Menu, MenuItemConstructorOptions } from 'electron'
|
||||
import { Command, CommandOpenSettings, CommandSelectWorkspace, CommandLogout } from '../ui/types'
|
||||
import { openFindInPageBar } from './findInPage'
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isLinux = process.platform === 'linux'
|
||||
@@ -40,6 +41,17 @@ export const addMenus = (sendCommand: (cmd: Command, ...args: any[]) => void): v
|
||||
{ role: isMac ? 'close' : 'quit' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Find…',
|
||||
click: (_item, browserWindow) => {
|
||||
openFindInPageBar(browserWindow as BrowserWindow | undefined)
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{ role: 'editMenu' },
|
||||
{ role: 'viewMenu' },
|
||||
{ role: 'windowMenu' }
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Config, MenuBarAction, NotificationParams, JumpListSpares, CommandClose
|
||||
import { getOptions } from './args'
|
||||
import { addMenus } from './standardMenu'
|
||||
import { dispatchMenuBarAction } from './customMenu'
|
||||
import { registerFindInPageIpcHandlers } from './findInPage'
|
||||
import { setupFindInPageOverlayForWindow } from './findInPageOverlayHost'
|
||||
import { addPermissionHandlers } from './permissions'
|
||||
import autoUpdater from './updater'
|
||||
import { generateId } from '@hcengineering/core'
|
||||
@@ -163,6 +165,11 @@ function runTheApp (): void {
|
||||
}
|
||||
setupWindowTitleBar(windowOptions)
|
||||
const childWindow = new BrowserWindow(windowOptions)
|
||||
try {
|
||||
await setupFindInPageOverlayForWindow(childWindow, sessionPartition, preloadScriptPath)
|
||||
} catch (err) {
|
||||
log.error('Find overlay setup failed (child window)', err)
|
||||
}
|
||||
await childWindow.loadFile(containerPagePath)
|
||||
hookOpenWindow(childWindow)
|
||||
})()
|
||||
@@ -263,6 +270,11 @@ function runTheApp (): void {
|
||||
}
|
||||
setupWindowTitleBar(windowOptions)
|
||||
mainWindow = new BrowserWindow(windowOptions)
|
||||
try {
|
||||
await setupFindInPageOverlayForWindow(mainWindow, sessionPartition, preloadScriptPath)
|
||||
} catch (err) {
|
||||
log.error('Find overlay setup failed (main window)', err)
|
||||
}
|
||||
app.dock?.setIcon(nativeImage.createFromPath(iconKey))
|
||||
if (isDev) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
@@ -382,6 +394,8 @@ function runTheApp (): void {
|
||||
showSelectAll: false
|
||||
})
|
||||
|
||||
registerFindInPageIpcHandlers()
|
||||
|
||||
ipcMain.on(IpcMessage.SetBadge, (_event: any, badge: number) => {
|
||||
app.dock?.setBadge(badge > 0 ? `${badge}` : '')
|
||||
app.badgeCount = badge
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title></title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,427 @@
|
||||
//
|
||||
// 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 { IPCMainExposed } from './types'
|
||||
|
||||
const DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Find UI is loaded in a separate BrowserView (see `findInPageOverlayHost.ts`) so
|
||||
* `findInPage` on the page webContents does not search the query in this input.
|
||||
* Shadow DOM keeps chrome text minimal. Match counts are not shown.
|
||||
*
|
||||
* Chromium moves focus to the matched text in the **page** webContents after
|
||||
* `findInPage`, so the overlay stops receiving keystrokes unless we put focus back
|
||||
* on the field after each search (caret is restored from before the call; we do not
|
||||
* steal focus on arbitrary blur — only after our own `findInPage`).
|
||||
*/
|
||||
export function setupDesktopFindInPageBar (electronApi: IPCMainExposed): void {
|
||||
const host = document.createElement('div')
|
||||
host.id = 'desktop-find-bar-host'
|
||||
host.style.display = 'none'
|
||||
|
||||
const shadow = host.attachShadow({ mode: 'open' })
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.textContent = shadowStyles
|
||||
|
||||
const root = document.createElement('div')
|
||||
root.className = 'bar'
|
||||
root.setAttribute('role', 'search')
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.className = 'field'
|
||||
input.type = 'text'
|
||||
input.placeholder = ''
|
||||
input.autocomplete = 'off'
|
||||
input.spellcheck = false
|
||||
input.setAttribute('aria-label', 'Find in page')
|
||||
|
||||
const nav = document.createElement('div')
|
||||
nav.className = 'nav'
|
||||
nav.setAttribute('role', 'group')
|
||||
nav.setAttribute('aria-label', 'Find matches')
|
||||
|
||||
const prevBtn = document.createElement('button')
|
||||
prevBtn.type = 'button'
|
||||
prevBtn.className = 'icon-btn prev'
|
||||
prevBtn.setAttribute('aria-label', 'Previous match')
|
||||
prevBtn.title = 'Previous (Shift+Enter)'
|
||||
|
||||
const nextBtn = document.createElement('button')
|
||||
nextBtn.type = 'button'
|
||||
nextBtn.className = 'icon-btn next'
|
||||
nextBtn.setAttribute('aria-label', 'Next match')
|
||||
nextBtn.title = 'Next (Enter)'
|
||||
|
||||
nav.appendChild(prevBtn)
|
||||
nav.appendChild(nextBtn)
|
||||
|
||||
const closeBtn = document.createElement('button')
|
||||
closeBtn.type = 'button'
|
||||
closeBtn.className = 'icon-btn close-x'
|
||||
closeBtn.setAttribute('aria-label', 'Close')
|
||||
closeBtn.title = 'Close'
|
||||
|
||||
root.appendChild(input)
|
||||
root.appendChild(nav)
|
||||
root.appendChild(closeBtn)
|
||||
shadow.appendChild(style)
|
||||
shadow.appendChild(root)
|
||||
document.body.appendChild(host)
|
||||
|
||||
let visible = false
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function getQuery (): string {
|
||||
return input.value.trim()
|
||||
}
|
||||
|
||||
function updateNavState (): void {
|
||||
const q = getQuery()
|
||||
prevBtn.disabled = q === ''
|
||||
nextBtn.disabled = q === ''
|
||||
}
|
||||
|
||||
function show (): void {
|
||||
const openingFromHidden = !visible
|
||||
visible = true
|
||||
host.style.display = 'block'
|
||||
try {
|
||||
electronApi.notifyFindOverlayLayout(true)
|
||||
} catch {
|
||||
/* ignore — overlay hit area may be wrong but avoid breaking the window */
|
||||
}
|
||||
input.focus()
|
||||
if (openingFromHidden) {
|
||||
input.select()
|
||||
}
|
||||
updateNavState()
|
||||
}
|
||||
|
||||
function hide (): void {
|
||||
visible = false
|
||||
host.style.display = 'none'
|
||||
prevBtn.disabled = true
|
||||
nextBtn.disabled = true
|
||||
try {
|
||||
electronApi.notifyFindOverlayLayout(false)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void electronApi.stopFindInPage('clearSelection').catch(() => {})
|
||||
}
|
||||
|
||||
async function find (text: string, options?: { findNext?: boolean, forward?: boolean }): Promise<void> {
|
||||
const selStart = input.selectionStart ?? input.value.length
|
||||
const selEnd = input.selectionEnd ?? input.value.length
|
||||
try {
|
||||
await electronApi.findInPage(text, {
|
||||
forward: options?.forward ?? true,
|
||||
findNext: options?.findNext ?? false
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
// Page view steals focus when a match is highlighted; without this, further typing
|
||||
// never reaches the input and scheduleFind appears to "stop working".
|
||||
input.focus({ preventScroll: true })
|
||||
const max = input.value.length
|
||||
try {
|
||||
input.setSelectionRange(Math.min(selStart, max), Math.min(selEnd, max))
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
|
||||
function runFindNext (forward: boolean): void {
|
||||
const q = getQuery()
|
||||
if (q === '') {
|
||||
return
|
||||
}
|
||||
void find(q, { findNext: true, forward })
|
||||
}
|
||||
|
||||
function scheduleFind (): void {
|
||||
if (debounceTimer !== undefined) {
|
||||
clearTimeout(debounceTimer)
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = undefined
|
||||
const q = getQuery()
|
||||
if (q === '') {
|
||||
void electronApi.stopFindInPage('clearSelection').catch(() => {})
|
||||
updateNavState()
|
||||
return
|
||||
}
|
||||
void find(q, { findNext: false, forward: true })
|
||||
}, DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
electronApi.onOpenFindBar(() => {
|
||||
show()
|
||||
if (getQuery() !== '') {
|
||||
scheduleFind()
|
||||
}
|
||||
})
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
scheduleFind()
|
||||
updateNavState()
|
||||
})
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (getQuery() === '') {
|
||||
return
|
||||
}
|
||||
runFindNext(!e.shiftKey)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
hide()
|
||||
}
|
||||
})
|
||||
|
||||
prevBtn.addEventListener('click', () => {
|
||||
runFindNext(false)
|
||||
})
|
||||
|
||||
nextBtn.addEventListener('click', () => {
|
||||
runFindNext(true)
|
||||
})
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
hide()
|
||||
})
|
||||
|
||||
document.addEventListener(
|
||||
'keydown',
|
||||
(e) => {
|
||||
if (!visible || e.key !== 'Escape') {
|
||||
return
|
||||
}
|
||||
const t = e.target as Node
|
||||
if (!root.contains(t)) {
|
||||
return
|
||||
}
|
||||
e.stopPropagation()
|
||||
},
|
||||
true
|
||||
)
|
||||
}
|
||||
|
||||
/** Left-pointing chevron; `.next::after` mirrors with `scaleX(-1)` for identical vertical alignment. */
|
||||
const chevronMaskUrl =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M15.41 16.59 10.83 12l4.58-4.59L14 6l-6 6 6 6 1.41-1.41z'/%3E%3C/svg%3E\")"
|
||||
|
||||
/** Close (×); same mask + `currentColor` treatment as `.prev` / `.next`. */
|
||||
const closeIconMaskUrl =
|
||||
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'/%3E%3C/svg%3E\")"
|
||||
|
||||
const shadowStyles = `
|
||||
:host {
|
||||
all: initial;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 2147483647;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
color: #1a1a1a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bar {
|
||||
background: rgba(42, 42, 46, 0.96);
|
||||
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
color: #e8e8e8;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .bar {
|
||||
background: rgba(42, 42, 46, 0.96);
|
||||
box-shadow: 0 2px 24px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.field {
|
||||
width: 200px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.field {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .field {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.field:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 42%, rgba(0, 0, 0, 0.2));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-color, #0b74da) 35%, transparent);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.field:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22));
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .field:focus {
|
||||
border-color: color-mix(in srgb, var(--accent-color, #0b74da) 50%, rgba(255, 255, 255, 0.22));
|
||||
}
|
||||
|
||||
.field::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding-left: 4px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nav {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .nav {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn.prev,
|
||||
.icon-btn.next,
|
||||
.icon-btn.close-x {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context([data-theme='theme-dark']) .icon-btn:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.28;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.prev::after,
|
||||
.next::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: ${chevronMaskUrl};
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-image: ${chevronMaskUrl};
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.next::after {
|
||||
transform: translate(-50%, -50%) scaleX(-1);
|
||||
}
|
||||
|
||||
.close-x::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: currentColor;
|
||||
-webkit-mask-image: ${closeIconMaskUrl};
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-image: ${closeIconMaskUrl};
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// 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 { setupDesktopFindInPageBar } from './findInPageBar'
|
||||
import { ipcMainExposed } from './typesUtils'
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
setupDesktopFindInPageBar(ipcMainExposed())
|
||||
} catch (err) {
|
||||
console.error('[find] Overlay UI failed to initialize:', err)
|
||||
}
|
||||
})
|
||||
@@ -43,5 +43,10 @@ export const IpcMessage = {
|
||||
OnDeepLinkHandler: 'on-deep-link-handler',
|
||||
HandleNotificationNavigation: 'handle-notification-navigation',
|
||||
HandleUpdateDownloadProgress: 'handle-update-download-progress',
|
||||
HandleAuth: 'handle-auth'
|
||||
HandleAuth: 'handle-auth',
|
||||
OpenFindBar: 'open-find-bar',
|
||||
FindInPage: 'find-in-page',
|
||||
StopFindInPage: 'stop-find-in-page',
|
||||
FindInPageResult: 'find-in-page-result',
|
||||
FindOverlayLayout: 'find-overlay-layout'
|
||||
} as const
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { BrandingMap, Config, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types'
|
||||
import { BrandingMap, Config, DesktopFoundInPageResult, IPCMainExposed, JumpListSpares, MenuBarAction, NotificationParams } from './types'
|
||||
import { IpcMessage } from './ipcMessages'
|
||||
|
||||
export function concatLink (host: string, path: string): string {
|
||||
@@ -201,6 +201,32 @@ const expose: IPCMainExposed = {
|
||||
},
|
||||
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => {
|
||||
ipcRenderer.on(IpcMessage.AutoLaunchSettingChanged, (_event, enabled: boolean) => { callback(enabled) })
|
||||
},
|
||||
|
||||
onOpenFindBar: (callback: () => void) => {
|
||||
ipcRenderer.removeAllListeners(IpcMessage.OpenFindBar)
|
||||
ipcRenderer.on(IpcMessage.OpenFindBar, () => {
|
||||
callback()
|
||||
})
|
||||
},
|
||||
|
||||
findInPage: async (text, options) => {
|
||||
return await ipcRenderer.invoke(IpcMessage.FindInPage, text, options ?? {})
|
||||
},
|
||||
|
||||
stopFindInPage: async (action) => {
|
||||
await ipcRenderer.invoke(IpcMessage.StopFindInPage, action)
|
||||
},
|
||||
|
||||
onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => {
|
||||
ipcRenderer.removeAllListeners(IpcMessage.FindInPageResult)
|
||||
ipcRenderer.on(IpcMessage.FindInPageResult, (_event, result: DesktopFoundInPageResult) => {
|
||||
callback(result)
|
||||
})
|
||||
},
|
||||
|
||||
notifyFindOverlayLayout: (visible: boolean) => {
|
||||
ipcRenderer.send(IpcMessage.FindOverlayLayout, visible)
|
||||
}
|
||||
}
|
||||
contextBridge.exposeInMainWorld('electron', expose)
|
||||
|
||||
@@ -111,6 +111,7 @@ export function buildHulyApplicationMenu (minimizeToTrayEnabled: boolean, autoLa
|
||||
.addMenuItem(MenuEditIndex, 'Paste', 'paste', 'Ctrl+V', 'p')
|
||||
.addMenuItem(MenuEditIndex, 'Delete', 'delete', 'Delete', 'd')
|
||||
.addSeparator(MenuEditIndex)
|
||||
.addMenuItem(MenuEditIndex, 'Find', 'find', 'Ctrl+F', 'f')
|
||||
.addMenuItem(MenuEditIndex, 'Select All', 'select-all', 'Ctrl+A', 'a')
|
||||
|
||||
const MenuViewIndex = 2
|
||||
|
||||
@@ -140,6 +140,7 @@ export const MenuBarActions = [
|
||||
'copy',
|
||||
'paste',
|
||||
'delete',
|
||||
'find',
|
||||
'select-all',
|
||||
'reload',
|
||||
'force-reload',
|
||||
@@ -200,7 +201,31 @@ export interface IPCMainExposed {
|
||||
onMinimizeToTraySettingChanged: (callback: (enabled: boolean) => void) => void
|
||||
isAutoLaunchEnabled: () => Promise<boolean>
|
||||
onAutoLaunchSettingChanged: (callback: (enabled: boolean) => void) => void
|
||||
|
||||
onOpenFindBar: (callback: () => void) => void
|
||||
findInPage: (text: string, options?: DesktopFindInPageOptions) => Promise<number>
|
||||
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => Promise<void>
|
||||
onFindInPageResult: (callback: (result: DesktopFoundInPageResult) => void) => void
|
||||
/** Resize the find BrowserView hit target (main process); overlay document only. */
|
||||
notifyFindOverlayLayout: (visible: boolean) => void
|
||||
}
|
||||
|
||||
export type SendCommandDelegate = (cmd: Command, ...args: any[]) => void
|
||||
export type WindowAction = () => void
|
||||
|
||||
/** Options passed to `webContents.findInPage` from the renderer. */
|
||||
export interface DesktopFindInPageOptions {
|
||||
forward?: boolean
|
||||
findNext?: boolean
|
||||
matchCase?: boolean
|
||||
wordStart?: boolean
|
||||
medialCapitalAsWordStart?: boolean
|
||||
}
|
||||
|
||||
/** Payload mirrored from Electron `found-in-page` (subset used by the find bar UI). */
|
||||
export interface DesktopFoundInPageResult {
|
||||
requestId: number
|
||||
activeMatchOrdinal: number
|
||||
matches: number
|
||||
finalUpdate: boolean
|
||||
}
|
||||
|
||||
@@ -126,7 +126,8 @@ module.exports = [
|
||||
{
|
||||
entry: {
|
||||
bundle: ['@hcengineering/theme/styles/global.scss', ...['./src/ui/index.ts']],
|
||||
'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts'
|
||||
'recorder-worker': '@hcengineering/recorder-resources/src/recorder-worker.ts',
|
||||
findInPageOverlay: './src/ui/findInPageOverlay.ts'
|
||||
},
|
||||
ignoreWarnings: [
|
||||
{
|
||||
@@ -340,6 +341,14 @@ module.exports = [
|
||||
isWindows: true
|
||||
}
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
template: './src/ui/find-in-page-overlay.ejs',
|
||||
filename: 'find-in-page-overlay.html',
|
||||
chunks: ['findInPageOverlay'],
|
||||
inject: 'body',
|
||||
publicPath: '',
|
||||
scriptLoading: 'blocking'
|
||||
}),
|
||||
...(!dev ? [new CompressionPlugin()] : []),
|
||||
// new MiniCssExtractPlugin({
|
||||
// filename: '[name].[id][contenthash].css'
|
||||
|
||||
@@ -582,6 +582,19 @@ export interface ClassPermission extends Permission {
|
||||
targetClass: Ref<Class<Doc>>
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface ModulePermissionGroup extends Doc {
|
||||
application: Ref<Doc>
|
||||
role: AccountRole
|
||||
permissions: Ref<Permission>[]
|
||||
disabledPermissions?: Ref<Permission>[]
|
||||
spaceClass: Ref<Class<Space>>
|
||||
enabled: boolean
|
||||
order?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
MarkupBlobRef,
|
||||
MigrationState,
|
||||
Mixin,
|
||||
ModulePermissionGroup,
|
||||
Obj,
|
||||
Permission,
|
||||
PersonId,
|
||||
@@ -180,7 +181,8 @@ export default plugin(coreId, {
|
||||
Sequence: '' as Ref<Class<Sequence>>,
|
||||
CustomSequence: '' as Ref<Class<CustomSequence>>,
|
||||
ClassCollaborators: '' as Ref<Class<ClassCollaborators<Doc>>>,
|
||||
Collaborator: '' as Ref<Class<Collaborator>>
|
||||
Collaborator: '' as Ref<Class<Collaborator>>,
|
||||
ModulePermissionGroup: '' as Ref<Class<ModulePermissionGroup>>
|
||||
},
|
||||
icon: {
|
||||
TypeString: '' as Asset,
|
||||
@@ -279,6 +281,7 @@ export default plugin(coreId, {
|
||||
Account: '' as IntlString,
|
||||
StatusCategory: '' as IntlString,
|
||||
Rank: '' as IntlString,
|
||||
Order: '' as IntlString,
|
||||
Members: '' as IntlString,
|
||||
Owners: '' as IntlString,
|
||||
Permission: '' as IntlString,
|
||||
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
import core, {
|
||||
type Account,
|
||||
AccountRole,
|
||||
type Class,
|
||||
type Doc,
|
||||
type ClassPermission,
|
||||
type Permission,
|
||||
hasAccountRole,
|
||||
type MeasureContext,
|
||||
type PersonId,
|
||||
type Ref,
|
||||
type SessionData,
|
||||
type Space,
|
||||
type Tx,
|
||||
@@ -22,7 +26,15 @@ import core, {
|
||||
import platform, { PlatformError, Severity, Status } from '@hcengineering/platform'
|
||||
import contact, { type Person } from '@hcengineering/contact'
|
||||
|
||||
/** Cached state loaded from GuestPermissionsSettings configuration document. */
|
||||
interface GuestPermissionsCache {
|
||||
roleAllowedClasses: Map<AccountRole, Set<Ref<Class<Doc>>>>
|
||||
}
|
||||
|
||||
export class GuestPermissionsMiddleware extends BaseMiddleware implements Middleware {
|
||||
private permissionsCache: GuestPermissionsCache | undefined = undefined
|
||||
private initPromise: Promise<void> | undefined = undefined
|
||||
|
||||
static async create (
|
||||
ctx: MeasureContext,
|
||||
context: PipelineContext,
|
||||
@@ -31,9 +43,86 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
|
||||
return new GuestPermissionsMiddleware(context, next)
|
||||
}
|
||||
|
||||
private async getPermissionsCache (ctx: MeasureContext): Promise<GuestPermissionsCache> {
|
||||
if (this.permissionsCache !== undefined) return this.permissionsCache
|
||||
if (this.initPromise === undefined) {
|
||||
this.initPromise = this.loadPermissionsCache(ctx)
|
||||
}
|
||||
await this.initPromise
|
||||
this.initPromise = undefined
|
||||
return this.permissionsCache ?? { roleAllowedClasses: new Map() }
|
||||
}
|
||||
|
||||
private async loadPermissionsCache (ctx: MeasureContext): Promise<void> {
|
||||
try {
|
||||
const docs = await this.findAll(ctx, core.class.ModulePermissionGroup, {}, {})
|
||||
if (docs.length > 0) {
|
||||
const rolePermissions = new Map<AccountRole, Set<Ref<Permission>>>()
|
||||
const allPermissionIds = new Set<Ref<Permission>>()
|
||||
for (const group of docs as any[]) {
|
||||
if (group.enabled === false) continue
|
||||
const role = ((group.role as AccountRole | undefined) ??
|
||||
(Array.isArray(group.roles) && group.roles.length > 0 ? (group.roles[0] as AccountRole) : undefined) ??
|
||||
AccountRole.Guest) as AccountRole
|
||||
const permissions = (group.permissions ?? []) as Ref<Permission>[]
|
||||
const disabled = new Set<Ref<Permission>>((group.disabledPermissions ?? []) as Ref<Permission>[])
|
||||
const current = rolePermissions.get(role) ?? new Set<Ref<Permission>>()
|
||||
for (const permissionId of permissions) {
|
||||
if (disabled.has(permissionId)) continue
|
||||
current.add(permissionId)
|
||||
allPermissionIds.add(permissionId)
|
||||
}
|
||||
rolePermissions.set(role, current)
|
||||
}
|
||||
const classPermissions =
|
||||
allPermissionIds.size > 0
|
||||
? await this.findAll(
|
||||
ctx,
|
||||
core.class.ClassPermission as Ref<Class<Doc>>,
|
||||
{ _id: { $in: Array.from(allPermissionIds) } } as any
|
||||
)
|
||||
: []
|
||||
const permissionToClass = new Map<Ref<Permission>, Ref<Class<Doc>>>(
|
||||
classPermissions
|
||||
.map(
|
||||
(permission) => [permission._id as Ref<Permission>, (permission as ClassPermission).targetClass] as const
|
||||
)
|
||||
.filter((entry): entry is readonly [Ref<Permission>, Ref<Class<Doc>>] => entry[1] !== undefined)
|
||||
)
|
||||
const roleAllowedClasses = new Map<AccountRole, Set<Ref<Class<Doc>>>>()
|
||||
for (const [role, permissions] of rolePermissions.entries()) {
|
||||
const allowedClasses = new Set<Ref<Class<Doc>>>()
|
||||
for (const permissionId of permissions) {
|
||||
const targetClass = permissionToClass.get(permissionId)
|
||||
if (targetClass !== undefined) allowedClasses.add(targetClass)
|
||||
}
|
||||
roleAllowedClasses.set(role, allowedClasses)
|
||||
}
|
||||
this.permissionsCache = { roleAllowedClasses }
|
||||
} else {
|
||||
this.permissionsCache = { roleAllowedClasses: new Map() }
|
||||
}
|
||||
} catch {
|
||||
this.permissionsCache = { roleAllowedClasses: new Map() }
|
||||
}
|
||||
}
|
||||
|
||||
private invalidateCacheIfNeeded (txes: Tx[]): void {
|
||||
for (const tx of txes) {
|
||||
if (TxProcessor.isExtendsCUD(tx._class)) {
|
||||
const cudTx = tx as TxCUD<Doc>
|
||||
if (cudTx.objectClass === core.class.ModulePermissionGroup) {
|
||||
this.permissionsCache = undefined
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async tx (ctx: MeasureContext<SessionData>, txes: Tx[]): Promise<TxMiddlewareResult> {
|
||||
const account = ctx.contextData.account
|
||||
if (hasAccountRole(account, AccountRole.User)) {
|
||||
this.invalidateCacheIfNeeded(txes)
|
||||
return await this.provideTx(ctx, txes)
|
||||
}
|
||||
|
||||
@@ -71,9 +160,64 @@ export class GuestPermissionsMiddleware extends BaseMiddleware implements Middle
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the covered-class ancestor of the objectClass if one exists in the new permissions model,
|
||||
* or undefined if the class is not covered.
|
||||
*/
|
||||
private getCoveredClass (
|
||||
objectClass: Ref<Class<Doc>>,
|
||||
allowedClasses: Set<Ref<Class<Doc>>>
|
||||
): Ref<Class<Doc>> | undefined {
|
||||
if (allowedClasses.size === 0) return undefined
|
||||
const h = this.context.hierarchy
|
||||
for (const coveredClass of allowedClasses) {
|
||||
if (h.isDerived(objectClass, coveredClass)) {
|
||||
return coveredClass
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private isCreatedByAccount (doc: Doc, account: Account): boolean {
|
||||
const creator = doc.createdBy
|
||||
if (creator === undefined) return false
|
||||
if (creator === account.primarySocialId) return true
|
||||
return account.socialIds.includes(creator)
|
||||
}
|
||||
|
||||
private async isGuestMutationOnOwnDoc (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
|
||||
if (tx._class !== core.class.TxUpdateDoc && tx._class !== core.class.TxRemoveDoc) return false
|
||||
const docs = await this.findAll(ctx, tx.objectClass, { _id: tx.objectId }, { limit: 1 })
|
||||
const doc = docs[0] as Doc | undefined
|
||||
if (doc === undefined) return false
|
||||
return this.isCreatedByAccount(doc, account)
|
||||
}
|
||||
|
||||
private async isForbiddenTx (ctx: MeasureContext, tx: TxCUD<Doc>, account: Account): Promise<boolean> {
|
||||
if (tx._class === core.class.TxMixin) return false
|
||||
return !(await this.hasMixinAccessLevel(ctx, tx, account))
|
||||
|
||||
// For TxCreateDoc, check the new permission model first for covered types.
|
||||
if (tx._class === core.class.TxCreateDoc) {
|
||||
const cache = await this.getPermissionsCache(ctx)
|
||||
const roleAllowedClasses = cache.roleAllowedClasses.get(account.role) ?? new Set<Ref<Class<Doc>>>()
|
||||
const coveredClass = this.getCoveredClass(tx.objectClass, roleAllowedClasses)
|
||||
if (coveredClass !== undefined) {
|
||||
return false
|
||||
}
|
||||
// Uncovered class: fall through to TxAccessLevel check.
|
||||
}
|
||||
|
||||
if (await this.hasMixinAccessLevel(ctx, tx, account)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (tx._class === core.class.TxUpdateDoc || tx._class === core.class.TxRemoveDoc) {
|
||||
if (await this.isGuestMutationOnOwnDoc(ctx, tx, account)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async isForbiddenSpaceTx (ctx: MeasureContext, tx: TxCUD<Space>, account: Account): Promise<boolean> {
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
/**
|
||||
* Tests for GuestPermissionsMiddleware
|
||||
*
|
||||
* Verifies that:
|
||||
* - Non-guest users pass through without restriction.
|
||||
* - DocGuest / ReadOnlyGuest users are always forbidden.
|
||||
* - For covered classes (resolved from module allowedPermissions):
|
||||
* new permission model is authoritative; TxAccessLevel is ignored.
|
||||
* Create in any space → permitted.
|
||||
* - For uncovered classes: TxAccessLevel fallback is used.
|
||||
*/
|
||||
|
||||
import core, {
|
||||
AccountRole,
|
||||
generateId,
|
||||
Hierarchy,
|
||||
MeasureMetricsContext,
|
||||
type Account,
|
||||
type Class,
|
||||
type Doc,
|
||||
type MeasureContext,
|
||||
type PersonId,
|
||||
type Ref,
|
||||
type SessionData,
|
||||
type Space,
|
||||
type Tx,
|
||||
TxFactory
|
||||
} from '@hcengineering/core'
|
||||
import type { PipelineContext, TxMiddlewareResult } from '@hcengineering/server-core'
|
||||
import { GuestPermissionsMiddleware } from '../guestPermissions'
|
||||
|
||||
const COVERED_CLASS = 'test:class:CoveredClass' as Ref<Class<Doc>>
|
||||
const UNCOVERED_CLASS = 'test:class:UncoveredClass' as Ref<Class<Doc>>
|
||||
const COVERED_CLASS_PERMISSION = 'test:permission:CoveredClassPermission' as Ref<Doc>
|
||||
const MODULE_PERMISSION_GROUP_CLASS = core.class.ModulePermissionGroup
|
||||
const ALLOWED_SPACE = 'test:space:Allowed' as Ref<Space>
|
||||
const FORBIDDEN_SPACE = 'test:space:Forbidden' as Ref<Space>
|
||||
|
||||
function makeAccount (role: AccountRole): Account {
|
||||
return {
|
||||
uuid: generateId() as any,
|
||||
role,
|
||||
primarySocialId: 'test' as PersonId,
|
||||
socialIds: ['test' as PersonId],
|
||||
fullSocialIds: []
|
||||
}
|
||||
}
|
||||
|
||||
function makeCtx (account: Account): MeasureContext<SessionData> {
|
||||
const ctx = new MeasureMetricsContext('test', {}) as MeasureContext<SessionData>
|
||||
ctx.contextData = {
|
||||
account,
|
||||
broadcast: { txes: [], queue: [], sessions: {} }
|
||||
} as any
|
||||
return ctx
|
||||
}
|
||||
|
||||
type FindAllFn = (ctx: MeasureContext, _class: Ref<Class<Doc>>, query: object, options?: object) => Promise<Doc[]>
|
||||
|
||||
function makePipelineContext (findAll?: FindAllFn): PipelineContext {
|
||||
const hierarchy = new Hierarchy()
|
||||
const model = { findAllSync: (_class: any, _query: any) => [] } as any
|
||||
return {
|
||||
workspace: { uuid: 'test-workspace' as any, url: 'test', dataId: 'test' as any },
|
||||
hierarchy,
|
||||
modelDb: model,
|
||||
branding: null as any,
|
||||
adapterManager: {} as any,
|
||||
storageAdapter: {} as any,
|
||||
contextVars: {},
|
||||
lastTx: '',
|
||||
lastHash: '',
|
||||
broadcastEvent: async () => {}
|
||||
} as any
|
||||
}
|
||||
|
||||
function makeMiddleware (
|
||||
findAll: FindAllFn,
|
||||
nextFn?: (ctx: MeasureContext, txes: Tx[]) => Promise<TxMiddlewareResult>
|
||||
): GuestPermissionsMiddleware {
|
||||
const context = makePipelineContext(findAll)
|
||||
const next = nextFn !== undefined ? { tx: nextFn } : { tx: async (_ctx: MeasureContext, _txes: Tx[]) => ({}) }
|
||||
const mw = new (GuestPermissionsMiddleware as any)(context, next)
|
||||
// Override findAll to inject our test data
|
||||
mw.findAll = findAll
|
||||
return mw
|
||||
}
|
||||
|
||||
function makeCreateTx (objectClass: Ref<Class<Doc>>, objectSpace: Ref<Space>): Tx {
|
||||
const factory = new TxFactory('test:account:System' as PersonId)
|
||||
return factory.createTxCreateDoc(objectClass, objectSpace, {})
|
||||
}
|
||||
|
||||
// Helper: buildGuestSettings - simulate the document that loadPermissionsCache would find
|
||||
function makeGuestSettingsDoc (allowedPermissions: Ref<Doc>[], disabledPermissions?: Ref<Doc>[]): Doc {
|
||||
return {
|
||||
_id: generateId(),
|
||||
_class: MODULE_PERMISSION_GROUP_CLASS,
|
||||
space: 'core:space:Workspace' as Ref<Space>,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: 'test' as PersonId,
|
||||
application: 'test:app:tracker' as Ref<Doc>,
|
||||
role: AccountRole.Guest,
|
||||
permissions: allowedPermissions,
|
||||
...(disabledPermissions !== undefined && disabledPermissions.length > 0 ? { disabledPermissions } : {}),
|
||||
spaceClass: 'core:class:Space' as Ref<Class<Doc>>,
|
||||
enabled: true
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('GuestPermissionsMiddleware', () => {
|
||||
// ─── Non-guest users pass through ───────────────────────────────────────────
|
||||
describe('non-guest users', () => {
|
||||
it('User role: passes through without restriction', async () => {
|
||||
let nextCalled = false
|
||||
const mw = makeMiddleware(
|
||||
async () => [],
|
||||
async (ctx, txes) => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
}
|
||||
)
|
||||
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.User))
|
||||
await mw.tx(ctx, [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('Owner role: passes through without restriction', async () => {
|
||||
let nextCalled = false
|
||||
const mw = makeMiddleware(
|
||||
async () => [],
|
||||
async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
}
|
||||
)
|
||||
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Owner))
|
||||
await mw.tx(ctx, [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── DocGuest / ReadOnlyGuest are always forbidden ──────────────────────────
|
||||
describe('DocGuest and ReadOnlyGuest', () => {
|
||||
it('DocGuest: throws Forbidden for any tx', async () => {
|
||||
const mw = makeMiddleware(async () => [])
|
||||
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.DocGuest))
|
||||
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('ReadOnlyGuest: throws Forbidden for any tx', async () => {
|
||||
const mw = makeMiddleware(async () => [])
|
||||
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.ReadOnlyGuest))
|
||||
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── New permission model (covered class) ───────────────────────────────────
|
||||
describe('covered class – new permission model', () => {
|
||||
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
|
||||
|
||||
const findAllWithSettings: FindAllFn = async (_ctx, _class) => {
|
||||
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
|
||||
if (_class === core.class.ClassPermission) {
|
||||
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function patchHierarchy (mw: GuestPermissionsMiddleware): void {
|
||||
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
|
||||
if (b === core.class.Space) return false
|
||||
return a === b
|
||||
}
|
||||
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
|
||||
}
|
||||
|
||||
it('allows create for covered class in any space (TxAccessLevel is irrelevant)', async () => {
|
||||
let nextCalled = false
|
||||
const mw = makeMiddleware(findAllWithSettings, async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
})
|
||||
patchHierarchy(mw)
|
||||
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await mw.tx(ctx, [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('also allows create in another space when class is covered', async () => {
|
||||
let nextCalled = false
|
||||
const mw = makeMiddleware(findAllWithSettings, async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
})
|
||||
patchHierarchy(mw)
|
||||
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await mw.tx(ctx, [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores permissions listed in disabledPermissions (falls back to TxAccessLevel)', async () => {
|
||||
const docWithDisabled = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION], [COVERED_CLASS_PERMISSION])
|
||||
const findAll: FindAllFn = async (_ctx, _class) => {
|
||||
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [docWithDisabled]
|
||||
if (_class === core.class.ClassPermission) {
|
||||
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const mw = makeMiddleware(findAll)
|
||||
patchHierarchy(mw)
|
||||
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Uncovered class falls back to TxAccessLevel ────────────────────────────
|
||||
describe('uncovered class – TxAccessLevel fallback', () => {
|
||||
it('forbids create when class has no TxAccessLevel mixin and no GuestPermissionsSettings', async () => {
|
||||
const mw = makeMiddleware(async () => [])
|
||||
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await expect(mw.tx(ctx, [tx])).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('allows create when TxAccessLevel.createAccessLevel === Guest (uncovered type)', async () => {
|
||||
// Settings exist but UNCOVERED_CLASS is NOT in allowedPermissions-derived classes
|
||||
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
|
||||
let nextCalled = false
|
||||
|
||||
const mw = makeMiddleware(
|
||||
async (_ctx, _class) => {
|
||||
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
|
||||
if (_class === core.class.ClassPermission) {
|
||||
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
|
||||
}
|
||||
return []
|
||||
},
|
||||
async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
}
|
||||
)
|
||||
|
||||
// Simulate TxAccessLevel mixin via hierarchy mock on the middleware context
|
||||
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
|
||||
if (_class === UNCOVERED_CLASS) {
|
||||
return { createAccessLevel: AccountRole.Guest }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
|
||||
if (b === core.class.Space) return false
|
||||
return a === b
|
||||
}
|
||||
|
||||
const tx = makeCreateTx(UNCOVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await mw.tx(ctx, [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Precedence: covered class ignores TxAccessLevel even if it would deny ──
|
||||
describe('precedence – new model overrides TxAccessLevel for covered types', () => {
|
||||
it('allows covered class create in allowed space regardless of missing TxAccessLevel', async () => {
|
||||
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
|
||||
let nextCalled = false
|
||||
|
||||
const mw = makeMiddleware(
|
||||
async (_ctx, _class) => {
|
||||
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
|
||||
if (_class === core.class.ClassPermission) {
|
||||
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
|
||||
}
|
||||
return []
|
||||
},
|
||||
async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
}
|
||||
)
|
||||
|
||||
// Ensure hierarchy says TxAccessLevel is absent for the covered class
|
||||
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => undefined
|
||||
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
|
||||
if (b === core.class.Space) return false
|
||||
return a === b
|
||||
}
|
||||
|
||||
const tx = makeCreateTx(COVERED_CLASS, ALLOWED_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await mw.tx(ctx, [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('allows covered class create in any space even if TxAccessLevel would deny', async () => {
|
||||
const settingsDoc = makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])
|
||||
|
||||
const mw = makeMiddleware(async (_ctx, _class) => {
|
||||
if (_class === MODULE_PERMISSION_GROUP_CLASS) return [settingsDoc]
|
||||
if (_class === core.class.ClassPermission) {
|
||||
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// TxAccessLevel would allow (createAccessLevel === Guest) – should be ignored
|
||||
;(mw as any).context.hierarchy.classHierarchyMixin = (_class: any, _mixin: any) => {
|
||||
if (_class === COVERED_CLASS) return { createAccessLevel: AccountRole.Guest }
|
||||
return undefined
|
||||
}
|
||||
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
|
||||
if (b === core.class.Space) return false
|
||||
return a === b
|
||||
}
|
||||
|
||||
const tx = makeCreateTx(COVERED_CLASS, FORBIDDEN_SPACE)
|
||||
const ctx = makeCtx(makeAccount(AccountRole.Guest))
|
||||
await mw.tx(ctx, [tx])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Own-document mutations for guests ───────────────────────────────────────
|
||||
describe('guest update/remove own documents', () => {
|
||||
const GUEST_SOCIAL = 'test:guest-social' as PersonId
|
||||
|
||||
function makeGuestAccountWithSocial (): Account {
|
||||
return {
|
||||
uuid: generateId() as any,
|
||||
role: AccountRole.Guest,
|
||||
primarySocialId: GUEST_SOCIAL,
|
||||
socialIds: [GUEST_SOCIAL],
|
||||
fullSocialIds: []
|
||||
}
|
||||
}
|
||||
|
||||
function patchHierarchyNoTxAccessLevel (mw: GuestPermissionsMiddleware): void {
|
||||
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
|
||||
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
|
||||
if (b === core.class.Space) return false
|
||||
return a === b
|
||||
}
|
||||
}
|
||||
|
||||
it('allows guest to update document created by same account', async () => {
|
||||
const objectId = generateId()
|
||||
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
|
||||
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
|
||||
return [
|
||||
{
|
||||
_id: objectId,
|
||||
_class: UNCOVERED_CLASS,
|
||||
space: ALLOWED_SPACE,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: GUEST_SOCIAL,
|
||||
createdBy: GUEST_SOCIAL
|
||||
} as any
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
let nextCalled = false
|
||||
const mw = makeMiddleware(findAll, async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
})
|
||||
patchHierarchyNoTxAccessLevel(mw)
|
||||
const factory = new TxFactory(GUEST_SOCIAL)
|
||||
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
|
||||
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('allows guest to remove document created by same account', async () => {
|
||||
const objectId = generateId()
|
||||
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
|
||||
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
|
||||
return [
|
||||
{
|
||||
_id: objectId,
|
||||
_class: UNCOVERED_CLASS,
|
||||
space: ALLOWED_SPACE,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: GUEST_SOCIAL,
|
||||
createdBy: GUEST_SOCIAL
|
||||
} as any
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
let nextCalled = false
|
||||
const mw = makeMiddleware(findAll, async () => {
|
||||
nextCalled = true
|
||||
return {}
|
||||
})
|
||||
patchHierarchyNoTxAccessLevel(mw)
|
||||
const factory = new TxFactory(GUEST_SOCIAL)
|
||||
const tx = factory.createTxRemoveDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId)
|
||||
await mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])
|
||||
expect(nextCalled).toBe(true)
|
||||
})
|
||||
|
||||
it('forbids guest to update document created by another account', async () => {
|
||||
const objectId = generateId()
|
||||
const otherSocial = 'test:other-social' as PersonId
|
||||
const findAll: FindAllFn = async (_ctx, _class, query: any) => {
|
||||
if (_class === UNCOVERED_CLASS && query?._id === objectId) {
|
||||
return [
|
||||
{
|
||||
_id: objectId,
|
||||
_class: UNCOVERED_CLASS,
|
||||
space: ALLOWED_SPACE,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: otherSocial,
|
||||
createdBy: otherSocial
|
||||
} as any
|
||||
]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const mw = makeMiddleware(findAll)
|
||||
patchHierarchyNoTxAccessLevel(mw)
|
||||
const factory = new TxFactory(GUEST_SOCIAL)
|
||||
const tx = factory.createTxUpdateDoc(UNCOVERED_CLASS, ALLOWED_SPACE, objectId, { name: 'x' } as any)
|
||||
await expect(mw.tx(makeCtx(makeGuestAccountWithSocial()), [tx])).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Cache invalidation ──────────────────────────────────────────────────────
|
||||
describe('cache invalidation', () => {
|
||||
it('invalidates cache when GuestPermissionsSettings is updated', async () => {
|
||||
const findAll: FindAllFn = async (_ctx, _class) => {
|
||||
if (_class === MODULE_PERMISSION_GROUP_CLASS) {
|
||||
return [makeGuestSettingsDoc([COVERED_CLASS_PERMISSION])]
|
||||
}
|
||||
if (_class === core.class.ClassPermission) {
|
||||
return [{ _id: COVERED_CLASS_PERMISSION, targetClass: COVERED_CLASS } as any]
|
||||
}
|
||||
return []
|
||||
}
|
||||
const mw = makeMiddleware(findAll)
|
||||
;(mw as any).context.hierarchy.isDerived = (a: any, b: any) => {
|
||||
if (b === core.class.Space) return false
|
||||
return a === b
|
||||
}
|
||||
;(mw as any).context.hierarchy.classHierarchyMixin = () => undefined
|
||||
|
||||
// First tx as guest should load cache
|
||||
const userCtx = makeCtx(makeAccount(AccountRole.User))
|
||||
const settingsTx: Tx = {
|
||||
_id: generateId(),
|
||||
_class: core.class.TxCreateDoc,
|
||||
space: core.space.Tx,
|
||||
modifiedOn: Date.now(),
|
||||
modifiedBy: 'test' as PersonId,
|
||||
objectId: generateId(),
|
||||
objectClass: MODULE_PERMISSION_GROUP_CLASS,
|
||||
objectSpace: 'core:space:Workspace' as Ref<Space>
|
||||
} as any
|
||||
|
||||
// Owner updates settings – should invalidate cache
|
||||
await mw.tx(userCtx, [settingsTx])
|
||||
// Cache should be cleared after settings update
|
||||
expect((mw as any).permissionsCache).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
Model,
|
||||
Prop,
|
||||
ReadOnly,
|
||||
TypeBoolean,
|
||||
TypeCollaborativeDoc,
|
||||
TypeNumber,
|
||||
TypeRank,
|
||||
@@ -94,6 +95,9 @@ export class TMasterTag extends TClass implements MasterTag {
|
||||
color?: number
|
||||
background?: number
|
||||
removed?: boolean
|
||||
|
||||
@Prop(TypeBoolean(), card.string.SingleColumn)
|
||||
singleColumn?: boolean
|
||||
}
|
||||
|
||||
@Model(card.class.Tag, core.class.Mixin)
|
||||
@@ -918,6 +922,31 @@ export function createModel (builder: Builder): void {
|
||||
card.ids.ManageMasterTags
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ClassPermission,
|
||||
core.space.Model,
|
||||
{
|
||||
label: card.string.AllowCreatingCards,
|
||||
scope: 'space',
|
||||
targetClass: card.class.Card
|
||||
},
|
||||
card.ids.GuestCardClassPermission
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: card.app.Card,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [card.ids.GuestCardClassPermission],
|
||||
spaceClass: card.class.CardSpace,
|
||||
enabled: true,
|
||||
order: 20
|
||||
},
|
||||
card.ids.ModulePermissionGroup
|
||||
)
|
||||
|
||||
builder.mixin(card.class.Card, core.class.Class, view.mixin.ClassFilters, {
|
||||
filters: ['space'],
|
||||
ignoreKeys: ['parent']
|
||||
|
||||
@@ -69,6 +69,20 @@ export function createModel (builder: Builder): void {
|
||||
chunter.app.Chunter
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: chunter.app.Chunter,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: chunter.class.ChunterSpace,
|
||||
enabled: true,
|
||||
order: 30
|
||||
},
|
||||
chunter.ids.ModulePermissionGroup
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
workbench.class.Widget,
|
||||
core.space.Model,
|
||||
|
||||
@@ -94,7 +94,8 @@ export default mergeIds(chunterId, chunter, {
|
||||
Channels: '' as Ref<Viewlet>
|
||||
},
|
||||
ids: {
|
||||
ChunterNotificationGroup: '' as Ref<NotificationGroup>
|
||||
ChunterNotificationGroup: '' as Ref<NotificationGroup>,
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
},
|
||||
space: {
|
||||
General: '' as Ref<Channel>,
|
||||
|
||||
@@ -1111,6 +1111,19 @@ export function createModel (builder: Builder): void {
|
||||
createPrintAction(documents.class.Document, documents.action.Print)
|
||||
|
||||
defineSpaceType(builder)
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: documents.app.Documents,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: documents.class.OrgSpace,
|
||||
enabled: true,
|
||||
order: 42
|
||||
},
|
||||
documents.ids.ModulePermissionGroup
|
||||
)
|
||||
definePermissions(builder)
|
||||
defineNotifications(builder)
|
||||
defineSearch(builder)
|
||||
|
||||
@@ -91,5 +91,8 @@ export default mergeIds(documentsId, documents, {
|
||||
DocumentsNotificationGroup: '' as Ref<NotificationGroup>,
|
||||
ContentNotification: '' as Ref<NotificationType>,
|
||||
StateNotification: '' as Ref<NotificationType>
|
||||
},
|
||||
ids: {
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
import { definePermissions } from './permissions'
|
||||
import {
|
||||
TAttributePermission,
|
||||
TModulePermissionGroup,
|
||||
TClassPermission,
|
||||
TPermission,
|
||||
TRole,
|
||||
@@ -136,6 +137,7 @@ export function createModel (builder: Builder): void {
|
||||
TSpaceTypeDescriptor,
|
||||
TRole,
|
||||
TPermission,
|
||||
TModulePermissionGroup,
|
||||
TAttributePermission,
|
||||
TClassPermission,
|
||||
TAttribute,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DOMAIN_MODEL,
|
||||
DOMAIN_SPACE,
|
||||
IndexKind,
|
||||
type ModulePermissionGroup,
|
||||
type AccountRole,
|
||||
type AccountUuid,
|
||||
type AnyAttribute,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
Prop,
|
||||
TypeAccountUuid,
|
||||
TypeBoolean,
|
||||
TypeNumber,
|
||||
TypeRef,
|
||||
TypeString,
|
||||
UX
|
||||
@@ -193,3 +195,27 @@ export class TTxAccessLevel extends TClass implements TxAccessLevel {
|
||||
updateAccessLevel?: AccountRole
|
||||
isIdentity?: boolean
|
||||
}
|
||||
|
||||
@Model(core.class.ModulePermissionGroup, core.class.Doc, DOMAIN_MODEL)
|
||||
export class TModulePermissionGroup extends TDoc implements ModulePermissionGroup {
|
||||
@Prop(TypeRef(core.class.Doc), core.string.AttachedTo)
|
||||
application!: Ref<Doc>
|
||||
|
||||
@Prop(TypeString(), core.string.Roles)
|
||||
role!: AccountRole
|
||||
|
||||
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
|
||||
permissions!: Ref<Permission>[]
|
||||
|
||||
@Prop(ArrOf(TypeRef(core.class.Permission)), core.string.Permission)
|
||||
disabledPermissions?: Ref<Permission>[]
|
||||
|
||||
@Prop(TypeRef(core.class.Class), core.string.Class)
|
||||
spaceClass!: Ref<Class<Space>>
|
||||
|
||||
@Prop(TypeBoolean(), core.string.Name)
|
||||
enabled!: boolean
|
||||
|
||||
@Prop(TypeNumber(), core.string.Order)
|
||||
order?: number
|
||||
}
|
||||
|
||||
@@ -540,6 +540,19 @@ export function createModel (builder: Builder): void {
|
||||
defineDocument(builder)
|
||||
|
||||
defineApplication(builder)
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: document.app.Documents,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: document.class.Teamspace,
|
||||
enabled: true,
|
||||
order: 40
|
||||
},
|
||||
document.ids.ModulePermissionGroup
|
||||
)
|
||||
definePermissions(builder)
|
||||
|
||||
builder.createDoc(core.class.DomainIndexConfiguration, core.space.Model, {
|
||||
|
||||
@@ -61,6 +61,9 @@ export default mergeIds(documentId, document, {
|
||||
Document: '' as Ref<ActionCategory>,
|
||||
Other: '' as Ref<TagCategory>
|
||||
},
|
||||
ids: {
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
},
|
||||
string: {
|
||||
ConfigDescription: '' as IntlString,
|
||||
ParentDocument: '' as IntlString,
|
||||
|
||||
@@ -842,5 +842,18 @@ export function createModel (builder: Builder): void {
|
||||
defineFile(builder)
|
||||
defineFileVersion(builder)
|
||||
defineApplication(builder)
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: drive.app.Drive,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: drive.class.Drive,
|
||||
enabled: false,
|
||||
order: 60
|
||||
},
|
||||
drive.ids.ModulePermissionGroup
|
||||
)
|
||||
definePermissions(builder)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,9 @@ export default mergeIds(driveId, drive, {
|
||||
RenameFolder: '' as ViewAction,
|
||||
RestoreFileVersion: '' as ViewAction
|
||||
},
|
||||
ids: {
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
},
|
||||
string: {
|
||||
Grid: '' as IntlString,
|
||||
Name: '' as IntlString,
|
||||
|
||||
@@ -266,6 +266,20 @@ export function createModel (builder: Builder): void {
|
||||
love.app.Love
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: love.app.Love,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: love.class.Office,
|
||||
enabled: true,
|
||||
order: 50
|
||||
},
|
||||
love.ids.ModulePermissionGroup
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
workbench.class.Widget,
|
||||
core.space.Model,
|
||||
|
||||
@@ -50,7 +50,8 @@ export default mergeIds(loveId, love, {
|
||||
ids: {
|
||||
Settings: '' as Ref<Doc>,
|
||||
LoveNotificationGroup: '' as Ref<NotificationGroup>,
|
||||
MeetingMinutesChatNotification: '' as Ref<NotificationType>
|
||||
MeetingMinutesChatNotification: '' as Ref<NotificationType>,
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
},
|
||||
function: {
|
||||
MeetingMinutesTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
|
||||
|
||||
@@ -312,6 +312,19 @@ export function createModel (builder: Builder): void {
|
||||
},
|
||||
setting.ids.Owners
|
||||
)
|
||||
builder.createDoc(
|
||||
setting.class.WorkspaceSettingCategory,
|
||||
core.space.Model,
|
||||
{
|
||||
name: 'guestPermissions',
|
||||
label: setting.string.GuestPermissionsSettings,
|
||||
icon: setting.icon.Members,
|
||||
component: setting.component.GuestPermissionsSettings,
|
||||
role: AccountRole.Owner,
|
||||
order: 1050
|
||||
},
|
||||
'setting:ids:AccountPermissionsSettings' as Ref<any>
|
||||
)
|
||||
builder.createDoc(
|
||||
setting.class.WorkspaceSettingCategory,
|
||||
core.space.Model,
|
||||
@@ -426,6 +439,7 @@ export function createModel (builder: Builder): void {
|
||||
},
|
||||
setting.ids.OfficeSettings
|
||||
)
|
||||
|
||||
// Currently remove Support item from settings
|
||||
// builder.createDoc(
|
||||
// setting.class.SettingsCategory,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import activity from '@hcengineering/activity'
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import core from '@hcengineering/model-core'
|
||||
import { SortingOrder, type FindOptions } from '@hcengineering/core'
|
||||
import { AccountRole, SortingOrder, type FindOptions } from '@hcengineering/core'
|
||||
|
||||
import { type Builder } from '@hcengineering/model'
|
||||
import view, { createAction } from '@hcengineering/model-view'
|
||||
@@ -202,6 +202,19 @@ export function createModel (builder: Builder): void {
|
||||
definePresenters(builder)
|
||||
|
||||
defineApplication(builder)
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: testManagement.app.TestManagement,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [],
|
||||
spaceClass: testManagement.class.TestProject,
|
||||
enabled: false,
|
||||
order: 70
|
||||
},
|
||||
testManagement.ids.ModulePermissionGroup
|
||||
)
|
||||
|
||||
builder.mixin(testManagement.class.TestCase, core.class.Class, view.mixin.ObjectIcon, {
|
||||
component: testManagement.component.TestCaseStatusPresenter
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
import { testManagementId } from '@hcengineering/test-management'
|
||||
import testManganement from '@hcengineering/test-management-resources/src/plugin'
|
||||
import type { Ref } from '@hcengineering/core'
|
||||
import type { Doc, Ref } from '@hcengineering/core'
|
||||
import { mergeIds } from '@hcengineering/platform'
|
||||
import { type AnyComponent } from '@hcengineering/ui/src/types'
|
||||
import type { ActionCategory } from '@hcengineering/view'
|
||||
@@ -48,5 +48,8 @@ export default mergeIds(testManagementId, testManganement, {
|
||||
TestPlanItemPresenter: '' as AnyComponent,
|
||||
CreateTestRunButton: '' as AnyComponent,
|
||||
RunTestPlanButton: '' as AnyComponent
|
||||
},
|
||||
ids: {
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -657,6 +657,31 @@ export function createModel (builder: Builder): void {
|
||||
order: 4000
|
||||
})
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ClassPermission,
|
||||
core.space.Model,
|
||||
{
|
||||
label: tracker.string.AllowCreatingIssues,
|
||||
scope: 'space',
|
||||
targetClass: tracker.class.Issue
|
||||
},
|
||||
tracker.ids.GuestIssueClassPermission
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: tracker.app.Tracker,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [tracker.ids.GuestIssueClassPermission],
|
||||
spaceClass: tracker.class.Project,
|
||||
enabled: true,
|
||||
order: 10
|
||||
},
|
||||
tracker.ids.ModulePermissionGroup
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
chunter.class.ChatMessageViewlet,
|
||||
core.space.Model,
|
||||
|
||||
@@ -41,7 +41,8 @@ export default mergeIds(trackerId, tracker, {
|
||||
ConfigDescription: '' as IntlString,
|
||||
AllProjects: '' as IntlString,
|
||||
MapRelatedIssues: '' as IntlString,
|
||||
Extensions: '' as IntlString
|
||||
Extensions: '' as IntlString,
|
||||
AllowCreatingIssues: '' as IntlString
|
||||
},
|
||||
activity: {
|
||||
StatusIcon: '' as AnyComponent,
|
||||
@@ -79,6 +80,8 @@ export default mergeIds(trackerId, tracker, {
|
||||
TrackerNotificationGroup: '' as Ref<NotificationGroup>,
|
||||
AssigneeNotification: '' as Ref<NotificationType>,
|
||||
BaseProjectType: '' as Ref<ProjectType>,
|
||||
GuestIssueClassPermission: '' as Ref<Doc>,
|
||||
ModulePermissionGroup: '' as Ref<Doc>,
|
||||
IssueUpdatedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
|
||||
IssueCreatedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
|
||||
IssueRemovedActivityViewlet: '' as Ref<DocUpdateMessageViewlet>,
|
||||
|
||||
@@ -898,6 +898,31 @@ function defineSettings (builder: Builder): void {
|
||||
},
|
||||
training.setting.Trainings
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ClassPermission,
|
||||
core.space.Model,
|
||||
{
|
||||
label: training.string.AllowToTakeTraining,
|
||||
scope: 'space',
|
||||
targetClass: training.class.TrainingAttempt
|
||||
},
|
||||
training.ids.GuestTrainingAttemptClassPermission
|
||||
)
|
||||
|
||||
builder.createDoc(
|
||||
core.class.ModulePermissionGroup,
|
||||
core.space.Model,
|
||||
{
|
||||
application: training.app.Training,
|
||||
role: AccountRole.Guest,
|
||||
permissions: [training.ids.GuestTrainingAttemptClassPermission],
|
||||
spaceClass: core.class.TypedSpace,
|
||||
enabled: true,
|
||||
order: 25
|
||||
},
|
||||
training.ids.ModulePermissionGroup
|
||||
)
|
||||
}
|
||||
|
||||
const columns = {
|
||||
|
||||
@@ -33,6 +33,10 @@ export default mergeIds(trainingId, training, {
|
||||
TrainingGroup: '' as Ref<NotificationGroup>,
|
||||
TrainingRequest: '' as Ref<NotificationType>
|
||||
},
|
||||
ids: {
|
||||
GuestTrainingAttemptClassPermission: '' as Ref<Doc>,
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
},
|
||||
|
||||
// TODO: Move function resources declarations to plugins/*-resources
|
||||
// Currently, dependencies look like this:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Karty",
|
||||
"Content": "Obsah",
|
||||
"CreateCard": "Vytvořit kartu",
|
||||
"AllowCreatingCards": "Povolit vytváření karet",
|
||||
"CreateMasterTag": "Vytvořit typ",
|
||||
"CreateTag": "Vytvořit štítek",
|
||||
"MasterTag": "Typ",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Zamknout sekci",
|
||||
"UnLockSection": "Odemknout sekci",
|
||||
"SectionLocked": "Sekce {section} zamknuta",
|
||||
"SectionUnlocked": "Sekce {section} odemknuta"
|
||||
"SectionUnlocked": "Sekce {section} odemknuta",
|
||||
"LayoutAuto": "Auto",
|
||||
"SingleColumn": "Jeden sloupec",
|
||||
"TwoColumns": "Dva sloupce"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Karten",
|
||||
"Content": "Inhalt",
|
||||
"CreateCard": "Karte erstellen",
|
||||
"AllowCreatingCards": "Erstellen von Karten erlauben",
|
||||
"CreateMasterTag": "Typ erstellen",
|
||||
"CreateTag": "Tag erstellen",
|
||||
"MasterTag": "Typ",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Sperren",
|
||||
"UnLockSection": "Entsperren",
|
||||
"SectionLocked": "Sektion {section} gesperrt",
|
||||
"SectionUnlocked": "Sektion {section} entsperrt"
|
||||
"SectionUnlocked": "Sektion {section} entsperrt",
|
||||
"LayoutAuto": "Auto",
|
||||
"SingleColumn": "Einspaltig",
|
||||
"TwoColumns": "Zwei spaltig"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Cards",
|
||||
"Content": "Content",
|
||||
"CreateCard": "Create Card",
|
||||
"AllowCreatingCards": "Allow creating cards",
|
||||
"CreateMasterTag": "Create Type",
|
||||
"CreateTag": "Create Tag",
|
||||
"MasterTag": "Type",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Lock section",
|
||||
"UnLockSection": "Unlock section",
|
||||
"SectionLocked": "Section {section} locked",
|
||||
"SectionUnlocked": "Section {section} unlocked"
|
||||
"SectionUnlocked": "Section {section} unlocked",
|
||||
"LayoutAuto": "Auto",
|
||||
"SingleColumn": "Single column",
|
||||
"TwoColumns": "Two columns"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Tarjetas",
|
||||
"Content": "Contenido",
|
||||
"CreateCard": "Crear Tarjeta",
|
||||
"AllowCreatingCards": "Permitir crear tarjetas",
|
||||
"CreateMasterTag": "Crear Tipo",
|
||||
"CreateTag": "Crear Etiqueta",
|
||||
"MasterTag": "Tipo",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Bloquear sección",
|
||||
"UnLockSection": "Desbloquear sección",
|
||||
"SectionLocked": "Sección {section} bloqueada",
|
||||
"SectionUnlocked": "Sección {section} desbloqueada"
|
||||
"SectionUnlocked": "Sección {section} desbloqueada",
|
||||
"LayoutAuto": "Auto",
|
||||
"SingleColumn": "Una columna",
|
||||
"TwoColumns": "Dos columnas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Cartes",
|
||||
"Content": "Contenu",
|
||||
"CreateCard": "Créer une carte",
|
||||
"AllowCreatingCards": "Autoriser la création de cartes",
|
||||
"CreateMasterTag": "Créer un type",
|
||||
"CreateTag": "Créer une étiquette",
|
||||
"MasterTag": "Type",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Verrouiller la section",
|
||||
"UnLockSection": "Déverrouiller la section",
|
||||
"SectionLocked": "Section {section} verrouillée",
|
||||
"SectionUnlocked": "Section {section} déverrouillée"
|
||||
"SectionUnlocked": "Section {section} déverrouillée",
|
||||
"LayoutAuto": "Auto",
|
||||
"SingleColumn": "Une colonne",
|
||||
"TwoColumns": "Deux colonnes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Carte",
|
||||
"Content": "Contenuto",
|
||||
"CreateCard": "Crea Carta",
|
||||
"AllowCreatingCards": "Consenti la creazione di carte",
|
||||
"CreateMasterTag": "Crea Tipo",
|
||||
"CreateTag": "Crea Tag",
|
||||
"MasterTag": "Tipo",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Blocca sezione",
|
||||
"UnLockSection": "Sblocca sezione",
|
||||
"SectionLocked": "Sezione {section} bloccata",
|
||||
"SectionUnlocked": "Sezione {section} sbloccata"
|
||||
"SectionUnlocked": "Sezione {section} sbloccata",
|
||||
"LayoutAuto": "Auto",
|
||||
"SingleColumn": "Una colonna",
|
||||
"TwoColumns": "Due colonne"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "カード",
|
||||
"Content": "コンテンツ",
|
||||
"CreateCard": "カードを作成",
|
||||
"AllowCreatingCards": "カードの作成を許可",
|
||||
"CreateMasterTag": "タイプを作成",
|
||||
"CreateTag": "タグを作成",
|
||||
"MasterTag": "タイプ",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "セクションをロック",
|
||||
"UnLockSection": "セクションをアンロック",
|
||||
"SectionLocked": "セクション {section} ロック済み",
|
||||
"SectionUnlocked": "セクション {section} アンロック済み"
|
||||
"SectionUnlocked": "セクション {section} アンロック済み",
|
||||
"LayoutAuto": "自動",
|
||||
"SingleColumn": "1列",
|
||||
"TwoColumns": "2列"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Cartões",
|
||||
"Content": "Conteúdo",
|
||||
"CreateCard": "Criar Cartão",
|
||||
"AllowCreatingCards": "Permitir criar cartões",
|
||||
"CreateMasterTag": "Criar Tipo",
|
||||
"CreateTag": "Criar Tag",
|
||||
"MasterTag": "Tipo",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Bloquear seção",
|
||||
"UnLockSection": "Desbloquear seção",
|
||||
"SectionLocked": "Seção {section} bloqueada",
|
||||
"SectionUnlocked": "Seção {section} desbloqueada"
|
||||
"SectionUnlocked": "Seção {section} desbloqueada",
|
||||
"LayoutAuto": "Automático",
|
||||
"SingleColumn": "Uma coluna",
|
||||
"TwoColumns": "Duas colunas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Cartões",
|
||||
"Content": "Conteúdo",
|
||||
"CreateCard": "Criar Cartão",
|
||||
"AllowCreatingCards": "Permitir criar cartões",
|
||||
"CreateMasterTag": "Criar Tipo",
|
||||
"CreateTag": "Criar Tag",
|
||||
"MasterTag": "Tipo",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Bloquear seção",
|
||||
"UnLockSection": "Desbloquear seção",
|
||||
"SectionLocked": "Seção {section} bloqueada",
|
||||
"SectionUnlocked": "Seção {section} desbloqueada"
|
||||
"SectionUnlocked": "Seção {section} desbloqueada",
|
||||
"LayoutAuto": "Automático",
|
||||
"SingleColumn": "Uma coluna",
|
||||
"TwoColumns": "Duas colunas"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Карты",
|
||||
"Content": "Содержание",
|
||||
"CreateCard": "Создать карту",
|
||||
"AllowCreatingCards": "Разрешить создание карт",
|
||||
"CreateMasterTag": "Создать тип",
|
||||
"CreateTag": "Создать тег",
|
||||
"MasterTag": "Тип",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Заблокировать секцию",
|
||||
"UnLockSection": "Разблокировать секцию",
|
||||
"SectionLocked": "Секция {section} заблокирована",
|
||||
"SectionUnlocked": "Секция {section} разблокирована"
|
||||
"SectionUnlocked": "Секция {section} разблокирована",
|
||||
"LayoutAuto": "Авто",
|
||||
"SingleColumn": "Один столбец",
|
||||
"TwoColumns": "Два столбца"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "Kartlar",
|
||||
"Content": "İçerik",
|
||||
"CreateCard": "Kart Oluştur",
|
||||
"AllowCreatingCards": "Kart oluşturmaya izin ver",
|
||||
"CreateMasterTag": "Tip Oluştur",
|
||||
"CreateTag": "Etiket Oluştur",
|
||||
"MasterTag": "Tip",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "Bölümü kilitle",
|
||||
"UnLockSection": "Bölümü kilitle",
|
||||
"SectionLocked": "Bölüm {section} kilitli",
|
||||
"SectionUnlocked": "Bölüm {section} kilidi açıldı"
|
||||
"SectionUnlocked": "Bölüm {section} kilidi açıldı",
|
||||
"LayoutAuto": "Otomatik",
|
||||
"SingleColumn": "Tek sütun",
|
||||
"TwoColumns": "İki sütun"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"Cards": "卡片",
|
||||
"Content": "内容",
|
||||
"CreateCard": "创建卡片",
|
||||
"AllowCreatingCards": "允许创建卡片",
|
||||
"CreateMasterTag": "创建类型",
|
||||
"CreateTag": "创建标签",
|
||||
"MasterTag": "类型",
|
||||
@@ -78,6 +79,9 @@
|
||||
"LockSection": "锁定部分",
|
||||
"UnLockSection": "解锁部分",
|
||||
"SectionLocked": "部分 {section} 已锁定",
|
||||
"SectionUnlocked": "部分 {section} 已解锁"
|
||||
"SectionUnlocked": "部分 {section} 已解锁",
|
||||
"LayoutAuto": "自动",
|
||||
"SingleColumn": "单列",
|
||||
"TwoColumns": "两列"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Card } from '@hcengineering/card'
|
||||
import { Card, MasterTag } from '@hcengineering/card'
|
||||
import { Doc, Mixin } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Button, Grid, IconDownOutline, IconUpOutline, resizeObserver } from '@hcengineering/ui'
|
||||
import card from '../plugin'
|
||||
import MasterTagAttributes from './MasterTagAttributes.svelte'
|
||||
import TagAttributes from './TagAttributes.svelte'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { viewStore } from '../utils'
|
||||
|
||||
export let value: Card
|
||||
export let readonly: boolean = false
|
||||
@@ -30,9 +31,20 @@
|
||||
const h = client.getHierarchy()
|
||||
|
||||
let width: number = 0
|
||||
let layoutMode: 'auto' | '1' | '2' = 'auto'
|
||||
|
||||
let columns = 1
|
||||
$: columns = width > 600 ? 2 : 1
|
||||
viewStore.subscribe((views) => {
|
||||
if (views[value._class] !== undefined) {
|
||||
layoutMode = views[value._class] as any
|
||||
} else {
|
||||
const masterTag = h.getClass(value._class) as MasterTag
|
||||
if (masterTag.singleColumn) {
|
||||
layoutMode = '1'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$: columns = layoutMode === 'auto' ? (width > 600 ? 2 : 1) : parseInt(layoutMode)
|
||||
|
||||
const tagAttributes: TagAttributes[] = []
|
||||
$: tagAttributes.length = mixins.length
|
||||
@@ -88,6 +100,7 @@
|
||||
<style lang="scss">
|
||||
.btn {
|
||||
margin-top: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tag {
|
||||
|
||||
@@ -16,41 +16,46 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Card } from '@hcengineering/card'
|
||||
import { NotificationContext } from '@hcengineering/communication-types'
|
||||
import { Ref, WithLookup } from '@hcengineering/core'
|
||||
import presence from '@hcengineering/presence'
|
||||
import {
|
||||
ComponentExtensions,
|
||||
createNotificationContextsQuery,
|
||||
createQuery,
|
||||
getClient
|
||||
} from '@hcengineering/presentation'
|
||||
import {
|
||||
Button,
|
||||
Component,
|
||||
createFocusManager,
|
||||
deviceOptionsStore as deviceInfo,
|
||||
DropdownIntlItem,
|
||||
DropdownLabelsPopupIntl,
|
||||
EditBox,
|
||||
eventToHTMLElement,
|
||||
FocusHandler,
|
||||
getCurrentLocation,
|
||||
IconDetailsFilled,
|
||||
IconMaxWidth,
|
||||
IconMoreH,
|
||||
IPanelState,
|
||||
navigate,
|
||||
Panel,
|
||||
IPanelState,
|
||||
deviceOptionsStore as deviceInfo
|
||||
showPopup
|
||||
} from '@hcengineering/ui'
|
||||
import presence from '@hcengineering/presence'
|
||||
import {
|
||||
createQuery,
|
||||
createNotificationContextsQuery,
|
||||
getClient,
|
||||
ComponentExtensions
|
||||
} from '@hcengineering/presentation'
|
||||
import { canChangeDoc, showMenu } from '@hcengineering/view-resources'
|
||||
import view from '@hcengineering/view'
|
||||
import { NotificationContext } from '@hcengineering/communication-types'
|
||||
import { canChangeDoc, showMenu } from '@hcengineering/view-resources'
|
||||
|
||||
import { permissionsStore } from '@hcengineering/contact-resources'
|
||||
import { afterUpdate } from 'svelte'
|
||||
import card from '../plugin'
|
||||
import { openCardInSidebar, setViewMode, viewStore } from '../utils'
|
||||
import CardIcon from './CardIcon.svelte'
|
||||
import TagsEditor from './TagsEditor.svelte'
|
||||
import CardVersionSelector from './CardVersionSelector.svelte'
|
||||
import EditCardNewContent from './EditCardNewContent.svelte'
|
||||
import ParentNamesPresenter from './ParentNamesPresenter.svelte'
|
||||
import { openCardInSidebar } from '../utils'
|
||||
import { afterUpdate } from 'svelte'
|
||||
import { permissionsStore } from '@hcengineering/contact-resources'
|
||||
import CardVersionSelector from './CardVersionSelector.svelte'
|
||||
import TagsEditor from './TagsEditor.svelte'
|
||||
|
||||
export let _id: Ref<Card>
|
||||
export let readonly: boolean = false
|
||||
@@ -155,6 +160,35 @@
|
||||
|
||||
$: _readonly = (readonly || doc?.readonly || doc?.readonlyFields?.includes('title')) ?? false
|
||||
$: updatePermissionForbidden = doc && !canChangeDoc(doc?._class, doc?.space, $permissionsStore)
|
||||
|
||||
function setLayout (mode: 'auto' | '1' | '2'): void {
|
||||
if (doc === undefined) return
|
||||
setViewMode(doc?._class, mode)
|
||||
}
|
||||
|
||||
function showViewPopup (ev: MouseEvent): void {
|
||||
if (doc === undefined) return
|
||||
const items: DropdownIntlItem[] = [
|
||||
{
|
||||
id: 'auto',
|
||||
label: card.string.LayoutAuto
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
label: card.string.SingleColumn
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
label: card.string.TwoColumns
|
||||
}
|
||||
]
|
||||
const selected = $viewStore[doc._class] ?? 'auto'
|
||||
showPopup(DropdownLabelsPopupIntl, { items, selected }, eventToHTMLElement(ev), async (result) => {
|
||||
if (result != null && result !== '') {
|
||||
setLayout(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<FocusHandler {manager} />
|
||||
@@ -224,6 +258,14 @@
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="utils">
|
||||
<Button
|
||||
kind="icon"
|
||||
icon={IconMaxWidth}
|
||||
iconProps={{ size: 'medium' }}
|
||||
on:click={(ev) => {
|
||||
showViewPopup(ev)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={IconDetailsFilled}
|
||||
iconProps={{ size: 'medium' }}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
{#if (isLocked && canUnlock) || (!isLocked && canLock)}
|
||||
<div class="lock-btn">
|
||||
<Button
|
||||
icon={isLocked ? Unlock : Lock}
|
||||
icon={isLocked ? Lock : Unlock}
|
||||
kind={'link'}
|
||||
size={'medium'}
|
||||
showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
{#if (isLocked && canUnlock) || (!isLocked && canLock)}
|
||||
<div class="lock-btn">
|
||||
<Button
|
||||
icon={isLocked ? Unlock : Lock}
|
||||
icon={isLocked ? Lock : Unlock}
|
||||
kind={'link'}
|
||||
size={'medium'}
|
||||
showTooltip={{ label: isLocked ? card.string.UnLockSection : card.string.LockSection }}
|
||||
|
||||
@@ -199,6 +199,13 @@
|
||||
on:change={enableVersioning}
|
||||
/>
|
||||
</div>
|
||||
<div class="mx-2">
|
||||
<ToggleWithLabel
|
||||
label={card.string.SingleColumn}
|
||||
on={masterTag.singleColumn}
|
||||
on:change={(e) => attributeUpdated('singleColumn', e.detail)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -167,6 +167,9 @@ export default mergeIds(cardId, card, {
|
||||
CardUpdated: '' as IntlString,
|
||||
CardCreated: '' as IntlString,
|
||||
MyCards: '' as IntlString,
|
||||
GotoMyCards: '' as IntlString
|
||||
GotoMyCards: '' as IntlString,
|
||||
SingleColumn: '' as IntlString,
|
||||
TwoColumns: '' as IntlString,
|
||||
LayoutAuto: '' as IntlString
|
||||
}
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ import CardSearchItem from './components/CardSearchItem.svelte'
|
||||
import CreateSpace from './components/navigator/CreateSpace.svelte'
|
||||
import card from './plugin'
|
||||
import { type NavigatorConfig } from './types'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export async function deleteMasterTag (tag: MasterTag | undefined, onDelete?: () => void): Promise<void> {
|
||||
if (tag !== undefined) {
|
||||
@@ -780,3 +781,15 @@ export function canUnlockSection (space: Ref<Space>, store: PermissionsStore): b
|
||||
if (allowed) return true
|
||||
return !store.restrictedSpaces.has(space)
|
||||
}
|
||||
|
||||
export const viewStore = writable<Record<Ref<MasterTag>, string>>(
|
||||
JSON.parse(localStorage.getItem('card.layout') ?? '{}')
|
||||
)
|
||||
|
||||
export function setViewMode (type: Ref<MasterTag>, mode: string): void {
|
||||
viewStore.update((views) => {
|
||||
views[type] = mode
|
||||
localStorage.setItem('card.layout', JSON.stringify(views))
|
||||
return views
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface MasterTag extends Class<Card> {
|
||||
background?: number
|
||||
removed?: boolean
|
||||
roles?: CollectionSize<Role>
|
||||
singleColumn?: boolean
|
||||
}
|
||||
|
||||
export interface Tag extends MasterTag, Mixin<Card> {}
|
||||
@@ -205,6 +206,7 @@ const cardPlugin = plugin(cardId, {
|
||||
AllCards: '' as IntlString,
|
||||
Favorites: '' as IntlString,
|
||||
CreateCard: '' as IntlString,
|
||||
AllowCreatingCards: '' as IntlString,
|
||||
Version: '' as IntlString,
|
||||
Versions: '' as IntlString,
|
||||
LockSection: '' as IntlString,
|
||||
@@ -222,7 +224,9 @@ const cardPlugin = plugin(cardId, {
|
||||
CommunicationMessages: '' as Ref<CardSection>
|
||||
},
|
||||
ids: {
|
||||
CardWidget: '' as Ref<Doc>
|
||||
CardWidget: '' as Ref<Doc>,
|
||||
GuestCardClassPermission: '' as Ref<Doc>,
|
||||
ModulePermissionGroup: '' as Ref<Doc>
|
||||
},
|
||||
component: {
|
||||
LabelsPresenter: '' as AnyComponent,
|
||||
|
||||
+4
-1
@@ -40,6 +40,8 @@
|
||||
export let draggedItem: Ref<DocumentMeta> | undefined = undefined
|
||||
export let draggedOver: Ref<DocumentMeta> | undefined = undefined
|
||||
|
||||
export let spaceIconFill: string | undefined = undefined
|
||||
|
||||
import DropArea from './DropArea.svelte'
|
||||
|
||||
const removeStates = [DocumentState.Obsolete, DocumentState.Deleted]
|
||||
@@ -72,7 +74,7 @@
|
||||
_id={docid}
|
||||
icon={isFolder ? documents.icon.Folder : documents.icon.Document}
|
||||
iconProps={{
|
||||
fill: isRemoved ? 'var(--dangerous-bg-color)' : 'currentColor'
|
||||
fill: isRemoved ? 'var(--dangerous-bg-color)' : (spaceIconFill ?? 'currentColor')
|
||||
}}
|
||||
{title}
|
||||
selected={selected === docid || selected === prjdoc._id}
|
||||
@@ -108,6 +110,7 @@
|
||||
{collapsedPrefix}
|
||||
{getMoreActions}
|
||||
level={level + 1}
|
||||
{spaceIconFill}
|
||||
{onDragStart}
|
||||
{onDragOver}
|
||||
{onDragEnd}
|
||||
|
||||
+4
-2
@@ -39,13 +39,15 @@
|
||||
}
|
||||
|
||||
$: root = tree.childrenOf(documents.ids.NoParent)
|
||||
|
||||
$: spaceIconFill = getPlatformColorForTextDef(space.name, $themeStore.dark).icon
|
||||
</script>
|
||||
|
||||
<TreeNode
|
||||
_id={space?._id}
|
||||
folderIcon
|
||||
iconProps={{
|
||||
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
|
||||
fill: spaceIconFill
|
||||
}}
|
||||
title={space.name}
|
||||
highlighted={selected !== undefined}
|
||||
@@ -57,5 +59,5 @@
|
||||
dispatch('selected', space)
|
||||
}}
|
||||
>
|
||||
<DocHierarchyLevel documentIds={root} {tree} {selected} {collapsedPrefix} on:selected />
|
||||
<DocHierarchyLevel documentIds={root} {tree} {selected} {collapsedPrefix} {spaceIconFill} on:selected />
|
||||
</TreeNode>
|
||||
|
||||
+5
-2
@@ -78,6 +78,8 @@
|
||||
|
||||
$: selected = getDocumentIdFromFragment(currentFragment ?? '')
|
||||
|
||||
$: spaceIconFill = getPlatformColorForTextDef(space.name, $themeStore.dark).icon
|
||||
|
||||
let project: Ref<Project> = documents.ids.NoProject
|
||||
$: void selectProject(space)
|
||||
|
||||
@@ -315,7 +317,7 @@
|
||||
_id={space?._id}
|
||||
folderIcon
|
||||
iconProps={{
|
||||
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
|
||||
fill: spaceIconFill
|
||||
}}
|
||||
title={space.name}
|
||||
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
|
||||
@@ -358,6 +360,7 @@
|
||||
{tree}
|
||||
documentIds={root}
|
||||
{selected}
|
||||
{spaceIconFill}
|
||||
getMoreActions={getDocumentActions}
|
||||
on:selected={(e) => {
|
||||
handleDocumentSelected(e.detail)
|
||||
@@ -382,7 +385,7 @@
|
||||
_id={doc._id}
|
||||
icon={documents.icon.Document}
|
||||
iconProps={{
|
||||
fill: 'currentColor'
|
||||
fill: spaceIconFill
|
||||
}}
|
||||
title={getDocumentName(doc)}
|
||||
actions={() => getDocumentActions(doc)}
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<path d="M4.5 11C3.11929 11 2 12.1193 2 13.5C2 13.7761 1.77614 14 1.5 14C1.22386 14 1 13.7761 1 13.5C1 11.567 2.567 10 4.5 10H6.5C8.433 10 10 11.567 10 13.5C10 13.7761 9.77614 14 9.5 14C9.22386 14 9 13.7761 9 13.5C9 12.1193 7.88071 11 6.5 11H4.5Z" />
|
||||
<path d="M10.5 2.99988C10.2238 2.99988 9.99995 3.22374 9.99995 3.49988C9.99995 3.77602 10.2238 3.99988 10.5 3.99988C10.7846 3.99988 11.0661 4.06066 11.3254 4.17815C11.5847 4.29564 11.8159 4.46714 12.0036 4.68119C12.1913 4.89523 12.3312 5.14688 12.4138 5.41931C12.4965 5.69174 12.52 5.97868 12.4828 6.26093C12.4457 6.54318 12.3487 6.81425 12.1984 7.05601C12.048 7.29777 11.8478 7.50465 11.6111 7.66282C11.3744 7.82098 11.1066 7.92679 10.8257 7.97316C10.5449 8.01954 10.2573 8.00541 9.98232 7.93173C9.71558 7.86026 9.44141 8.01855 9.36994 8.28528C9.29847 8.55202 9.45677 8.82618 9.7235 8.89766C10.136 9.00818 10.5673 9.02937 10.9886 8.95981C11.41 8.89025 11.8116 8.73153 12.1667 8.49429C12.5217 8.25704 12.8221 7.94672 13.0476 7.58408C13.2731 7.22144 13.4186 6.81484 13.4743 6.39146C13.53 5.96807 13.4947 5.53767 13.3708 5.12902C13.2468 4.72038 13.037 4.3429 12.7555 4.02184C12.4739 3.70078 12.127 3.44353 11.7381 3.26729C11.3491 3.09105 10.927 2.99988 10.5 2.99988Z" />
|
||||
</symbol>
|
||||
<symbol id="members" viewBox="0 0 32 32">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.7,5.7c-1.4,0-2.7,0.5-3.5,1.5c-0.9,0.9-1.3,2.3-1.2,3.7c0.2,2.7,2.3,5.1,4.8,5.1c2.5,0,4.6-2.3,4.8-5.1C25.7,8,23.5,5.7,20.7,5.7z M18.5,8.5C18,9,17.7,9.8,17.7,10.8c0.1,2,1.6,3.3,2.9,3.3c1.3,0,2.8-1.3,2.9-3.3c0.1-1.9-1.1-3.2-2.9-3.2C19.8,7.6,19,7.9,18.5,8.5z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.4,24.1c1.1-4.2,5.2-6.3,9.3-6.3c4,0,8.2,2,9.3,6.3c0.2,0.9-0.4,2.1-1.6,2.1H13C11.7,26.3,11.2,25.1,11.4,24.1z M13.3,24.4h14.8c-0.9-3-3.9-4.7-7.4-4.7C17.2,19.7,14.1,21.4,13.3,24.4z" />
|
||||
<path d="M9.6,16.2c-2.1,0-3.9-1.9-4-4.3c-0.1-1.2,0.3-2.3,1-3.1c0.8-0.8,1.8-1.2,3-1.2c1.2,0,2.2,0.4,3,1.3c0.8,0.8,1.1,1.9,1.1,3.1C13.5,14.3,11.7,16.2,9.6,16.2z M9.6,9.5C9,9.5,8.4,9.7,8,10.1c-0.4,0.4-0.6,1-0.5,1.7c0.1,1.4,1.1,2.5,2.2,2.5s2.1-1.2,2.2-2.5c0-0.7-0.2-1.3-0.6-1.7C10.9,9.7,10.3,9.5,9.6,9.5z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2,22.5c0.9-3.5,4.3-5.2,7.6-5.2c1.3,0,2.6,0.2,3.8,0.8c0.5,0.2,0.7,0.8,0.5,1.2c-0.2,0.5-0.8,0.7-1.2,0.5c-0.9-0.4-1.9-0.6-3.1-0.6c-2.6,0-4.9,1.2-5.7,3.4H10c0.5,0,0.9,0.4,0.9,0.9s-0.4,0.9-0.9,0.9H3.5C2.4,24.4,1.8,23.3,2,22.5L2,22.5z" />
|
||||
</symbol>
|
||||
<symbol id="password" viewBox="0 0 16 16">
|
||||
<path d="M11 6C11.5523 6 12 5.55228 12 5C12 4.44772 11.5523 4 11 4C10.4477 4 10 4.44772 10 5C10 5.55228 10.4477 6 11 6Z" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.5 1C8.01472 1 6 3.01472 6 5.5C6 5.89536 6.05111 6.27942 6.14726 6.64563L1.29289 11.5C1.10536 11.6875 1 11.9419 1 12.2071V14.5C1 14.7761 1.22386 15 1.5 15H3.79289C4.05811 15 4.31246 14.8946 4.5 14.7071L9.35437 9.85274C9.72058 9.94889 10.1046 10 10.5 10C12.9853 10 15 7.98528 15 5.5C15 3.01472 12.9853 1 10.5 1ZM7 5.5C7 3.567 8.567 2 10.5 2C12.433 2 14 3.567 14 5.5C14 7.433 12.433 9 10.5 9C10.1048 9 9.72588 8.93469 9.37284 8.81468C9.19252 8.75339 8.99304 8.79986 8.85837 8.93453L8 9.79289L7.85355 9.64645C7.65829 9.45118 7.34171 9.45118 7.14645 9.64645C6.95118 9.84171 6.95118 10.1583 7.14645 10.3536L7.29289 10.5L6.5 11.2929L5.85355 10.6464C5.65829 10.4512 5.34171 10.4512 5.14645 10.6464C4.95118 10.8417 4.95118 11.1583 5.14645 11.3536L5.79289 12L5 12.7929L4.35355 12.1464C4.15829 11.9512 3.84171 11.9512 3.64645 12.1464C3.45118 12.3417 3.45118 12.6583 3.64645 12.8536L4.29289 13.5L3.79289 14H2V12.2071L7.06547 7.14163C7.20014 7.00696 7.24661 6.80748 7.18532 6.62716C7.06531 6.27412 7 5.8952 7 5.5Z" />
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
@@ -212,6 +212,9 @@
|
||||
"OfficeDefaultSettings": "Výchozí nastavení pro jednací místnosti",
|
||||
"DefaultStartWithTranscription": "Povolit přepis v nových kancelářích",
|
||||
"DefaultStartWithRecording": "Povolit záznam v nových kancelářích",
|
||||
"GuestPermissionsSettings": "Oprávnění hostů",
|
||||
"GuestPermissionsModulePermissions": "Oprávnění modulů",
|
||||
"GuestPermissionsModulePermissionsHint": "Vyberte, které moduly mohou hosté používat, a níže upravte oprávnění pro jednotlivé aplikace.",
|
||||
"ImportDocumentPermission": "Importovat dokumenty",
|
||||
"ImportDocumentDescription": "Umožňuje uživatelům importovat dokumenty do pracovního prostoru",
|
||||
"SelectUsers": "Vybrat uživatele",
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"OfficeDefaultSettings": "Standardeinstellungen für Besprechungsräume",
|
||||
"DefaultStartWithTranscription": "Transkription in neuen Büroräumen aktivieren",
|
||||
"DefaultStartWithRecording": "Aufnahme in neuen Büroräumen aktivieren",
|
||||
"GuestPermissionsSettings": "Gastberechtigungen",
|
||||
"GuestPermissionsModulePermissions": "Modulberechtigungen",
|
||||
"GuestPermissionsModulePermissionsHint": "Wählen Sie, welche Module Gäste nutzen dürfen, und passen Sie unten die Berechtigungen für jede App an.",
|
||||
"ImportDocumentPermission": "Dokumente importieren",
|
||||
"ImportDocumentDescription": "Gewährt Benutzern die Möglichkeit, Dokumente in den Arbeitsbereich zu importieren",
|
||||
"SelectUsers": "Benutzer auswählen",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"OfficeDefaultSettings": "Default settings for meeting rooms",
|
||||
"DefaultStartWithTranscription": "Enable transcription in new office rooms",
|
||||
"DefaultStartWithRecording": "Enable recording in new office rooms",
|
||||
"GuestPermissionsSettings": "Guest permissions",
|
||||
"GuestPermissionsModulePermissions": "Module permissions",
|
||||
"GuestPermissionsModulePermissionsHint": "Choose which modules guests can use, then adjust permissions for each app below.",
|
||||
"ImportDocumentPermission": "Import documents",
|
||||
"ImportDocumentDescription": "Grants users ability to import documents into the workspace",
|
||||
"SelectUsers": "Select users",
|
||||
|
||||
@@ -205,6 +205,9 @@
|
||||
"OfficeDefaultSettings": "Configuración predeterminada para salas de reuniones",
|
||||
"DefaultStartWithTranscription": "Habilitar transcripción en nuevas oficinas",
|
||||
"DefaultStartWithRecording": "Habilitar grabación en nuevas oficinas",
|
||||
"GuestPermissionsSettings": "Permisos de invitados",
|
||||
"GuestPermissionsModulePermissions": "Permisos de módulos",
|
||||
"GuestPermissionsModulePermissionsHint": "Elija qué módulos pueden usar los invitados y luego ajuste los permisos de cada aplicación a continuación.",
|
||||
"ImportDocumentPermission": "Importar documentos",
|
||||
"ImportDocumentDescription": "Otorga a los usuarios la capacidad de importar documentos al espacio de trabajo",
|
||||
"SelectUsers": "Seleccionar usuarios",
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"OfficeDefaultSettings": "Paramètres par défaut pour les salles de réunion",
|
||||
"DefaultStartWithTranscription": "Activer la transcription dans les nouveaux bureaux",
|
||||
"DefaultStartWithRecording": "Activer l'enregistrement dans les nouveaux bureaux",
|
||||
"GuestPermissionsSettings": "Permissions des invités",
|
||||
"GuestPermissionsModulePermissions": "Permissions des modules",
|
||||
"GuestPermissionsModulePermissionsHint": "Choisissez les modules accessibles aux invités, puis ajustez les permissions pour chaque application ci-dessous.",
|
||||
"ImportDocumentPermission": "Importer des documents",
|
||||
"ImportDocumentDescription": "Accorde aux utilisateurs la possibilité d'importer des documents dans l'espace de travail",
|
||||
"SelectUsers": "Sélectionner des utilisateurs",
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"OfficeDefaultSettings": "Impostazioni predefinite per le sale riunioni",
|
||||
"DefaultStartWithTranscription": "Abilita trascrizione nelle nuove stanze dell'ufficio",
|
||||
"DefaultStartWithRecording": "Abilita registrazione nelle nuove stanze dell'ufficio",
|
||||
"GuestPermissionsSettings": "Permessi ospite",
|
||||
"GuestPermissionsModulePermissions": "Permessi dei moduli",
|
||||
"GuestPermissionsModulePermissionsHint": "Scegli quali moduli possono usare gli ospiti, poi regola i permessi per ogni app qui sotto.",
|
||||
"ImportDocumentPermission": "Importa documenti",
|
||||
"ImportDocumentDescription": "Concede agli utenti la possibilità di importare documenti nell'area di lavoro",
|
||||
"SelectUsers": "Seleziona utenti",
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"OfficeDefaultSettings": "会議室のデフォルト設定",
|
||||
"DefaultStartWithTranscription": "新しいオフィスルームで文字起こしを有効にする",
|
||||
"DefaultStartWithRecording": "新しいオフィスルームで録画を有効にする",
|
||||
"GuestPermissionsSettings": "ゲストの権限",
|
||||
"GuestPermissionsModulePermissions": "モジュールの権限",
|
||||
"GuestPermissionsModulePermissionsHint": "ゲストが利用できるモジュールを選び、その下で各アプリの権限を調整します。",
|
||||
"ImportDocumentPermission": "ドキュメントをインポート",
|
||||
"ImportDocumentDescription": "ユーザーにワークスペースにドキュメントをインポートする機能を付与します",
|
||||
"SelectUsers": "ユーザーを選択",
|
||||
|
||||
@@ -205,6 +205,9 @@
|
||||
"OfficeDefaultSettings": "Configurações padrão para salas de reunião",
|
||||
"DefaultStartWithTranscription": "Habilitar transcrição em novos escritórios",
|
||||
"DefaultStartWithRecording": "Habilitar gravação em novos escritórios",
|
||||
"GuestPermissionsSettings": "Permissões de convidados",
|
||||
"GuestPermissionsModulePermissions": "Permissões de módulos",
|
||||
"GuestPermissionsModulePermissionsHint": "Escolha quais módulos os convidados podem usar e, em seguida, ajuste as permissões de cada aplicativo abaixo.",
|
||||
"ImportDocumentPermission": "Importar documentos",
|
||||
"ImportDocumentDescription": "Concede aos usuários a capacidade de importar documentos para o espaço de trabalho",
|
||||
"SelectUsers": "Selecionar usuários",
|
||||
|
||||
@@ -205,6 +205,9 @@
|
||||
"OfficeDefaultSettings": "Configurações padrão para salas de reunião",
|
||||
"DefaultStartWithTranscription": "Habilitar transcrição em novos escritórios",
|
||||
"DefaultStartWithRecording": "Habilitar gravação em novos escritórios",
|
||||
"GuestPermissionsSettings": "Permissões de convidados",
|
||||
"GuestPermissionsModulePermissions": "Permissões de módulos",
|
||||
"GuestPermissionsModulePermissionsHint": "Escolha quais módulos os convidados podem usar e, em seguida, ajuste as permissões de cada aplicação abaixo.",
|
||||
"ImportDocumentPermission": "Importar documentos",
|
||||
"ImportDocumentDescription": "Concede aos usuários a capacidade de importar documentos para o espaço de trabalho",
|
||||
"SelectUsers": "Selecionar usuários",
|
||||
|
||||
@@ -215,6 +215,9 @@
|
||||
"OfficeDefaultSettings": "Настройки по умолчанию для переговорных",
|
||||
"DefaultStartWithTranscription": "Включить транскрипцию в новых комнатах",
|
||||
"DefaultStartWithRecording": "Включить запись в новых комнатах",
|
||||
"GuestPermissionsSettings": "Права гостей",
|
||||
"GuestPermissionsModulePermissions": "Права модулей",
|
||||
"GuestPermissionsModulePermissionsHint": "Выберите, какие модули доступны гостям, затем настройте права для каждого приложения ниже.",
|
||||
"ImportDocumentPermission": "Импорт документов",
|
||||
"ImportDocumentDescription": "Предоставляет пользователям возможность импортировать документы в рабочее пространство",
|
||||
"SelectUsers": "Выбрать пользователей",
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"OfficeDefaultSettings": "Toplantı odaları için varsayılan ayarlar",
|
||||
"DefaultStartWithTranscription": "Yeni ofis odalarında transkripsiyonu etkinleştir",
|
||||
"DefaultStartWithRecording": "Yeni ofis odalarında kaydı etkinleştir",
|
||||
"GuestPermissionsSettings": "Misafir izinleri",
|
||||
"GuestPermissionsModulePermissions": "Modül izinleri",
|
||||
"GuestPermissionsModulePermissionsHint": "Misafirlerin hangi modülleri kullanabileceğini seçin, ardından her uygulama için izinleri aşağıdan ayarlayın.",
|
||||
"ImportDocumentPermission": "Belgeleri içe aktar",
|
||||
"ImportDocumentDescription": "Kullanıcılara çalışma alanına belge içe aktarma yeteneği verir",
|
||||
"SelectUsers": "Kullanıcıları seç",
|
||||
|
||||
@@ -214,6 +214,9 @@
|
||||
"OfficeDefaultSettings": "会议室的默认设置",
|
||||
"DefaultStartWithTranscription": "在新办公室启用转录",
|
||||
"DefaultStartWithRecording": "在新办公室启用录制",
|
||||
"GuestPermissionsSettings": "访客权限",
|
||||
"GuestPermissionsModulePermissions": "模块权限",
|
||||
"GuestPermissionsModulePermissionsHint": "选择访客可以使用哪些模块,然后在下方调整每个应用的权限。",
|
||||
"ImportDocumentPermission": "导入文档",
|
||||
"ImportDocumentDescription": "授予用户将文档导入工作区的权限",
|
||||
"SelectUsers": "选择用户",
|
||||
|
||||
@@ -20,6 +20,7 @@ const icons = require('../assets/icons.svg') as string // eslint-disable-line
|
||||
loadMetadata(setting.icon, {
|
||||
AccountSettings: `${icons}#accountSettings`,
|
||||
Owners: `${icons}#owners`,
|
||||
Members: `${icons}#members`,
|
||||
Password: `${icons}#password`,
|
||||
Setting: `${icons}#settings`,
|
||||
Integrations: `${icons}#integration`,
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<!--
|
||||
// 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 core, { ModulePermissionGroup, type Doc, type Permission, type Ref } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel, getMetadata, type IntlString } from '@hcengineering/platform'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import workbench, { type Application } from '@hcengineering/workbench'
|
||||
import { Breadcrumb, Header, Icon, Label, Loading, Scroller, Toggle } from '@hcengineering/ui'
|
||||
import setting from '@hcengineering/setting'
|
||||
|
||||
let loadingSettings = true
|
||||
let loadingPermissions = true
|
||||
let workspaceAppsReady = false
|
||||
|
||||
let moduleGroups: ModulePermissionGroup[] = []
|
||||
let permissionsMap: Map<Ref<Permission>, Permission> = new Map<Ref<Permission>, Permission>()
|
||||
let hiddenApplicationIds: Array<Ref<Application>> = []
|
||||
|
||||
const excludedApplicationIds = getMetadata(workbench.metadata.ExcludedApplications) ?? []
|
||||
|
||||
const client = getClient()
|
||||
const moduleGroupsQuery = createQuery()
|
||||
const permissionsQuery = createQuery()
|
||||
const hiddenAppsQuery = createQuery()
|
||||
|
||||
$: moduleGroupsQuery.query(core.class.ModulePermissionGroup, {}, (res) => {
|
||||
moduleGroups = res as unknown as ModulePermissionGroup[]
|
||||
loadingSettings = false
|
||||
})
|
||||
|
||||
$: permissionsQuery.query(core.class.Permission, {}, (res) => {
|
||||
permissionsMap = new Map((res as Permission[]).map((permission) => [permission._id, permission]))
|
||||
loadingPermissions = false
|
||||
})
|
||||
|
||||
$: hiddenAppsQuery.query(workbench.class.HiddenApplication, { space: core.space.Workspace }, (res) => {
|
||||
hiddenApplicationIds = res.map((r) => r.attachedTo)
|
||||
workspaceAppsReady = true
|
||||
})
|
||||
|
||||
/** Same notion of “available in this workspace” as the app switcher: model apps minus hidden/excluded. */
|
||||
$: workspaceApplications = client
|
||||
.getModel()
|
||||
.findAllSync<Application>(workbench.class.Application, {
|
||||
hidden: false,
|
||||
_id: { $nin: excludedApplicationIds }
|
||||
})
|
||||
.filter((app) => !hiddenApplicationIds.includes(app._id))
|
||||
|
||||
$: applicationsMap = new Map<Ref<Doc>, Application>(
|
||||
workspaceApplications.map((application) => [application._id as Ref<Doc>, application])
|
||||
)
|
||||
|
||||
$: loading = loadingSettings || loadingPermissions || !workspaceAppsReady
|
||||
|
||||
/** Ignore permission groups for applications not enabled in this workspace. */
|
||||
$: visibleModuleGroups = moduleGroups.filter((group) => applicationsMap.has(group.application))
|
||||
|
||||
$: sortedVisibleModuleGroups = [...visibleModuleGroups].sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity))
|
||||
|
||||
function getApplicationLabel (applicationId: Ref<Doc>): IntlString {
|
||||
return applicationsMap.get(applicationId)?.label ?? getEmbeddedLabel(applicationId)
|
||||
}
|
||||
|
||||
function getApplication (applicationId: Ref<Doc>): Application | undefined {
|
||||
return applicationsMap.get(applicationId)
|
||||
}
|
||||
|
||||
function getDisabledPermissions (group: ModulePermissionGroup): Set<Ref<Permission>> {
|
||||
return new Set(group.disabledPermissions ?? [])
|
||||
}
|
||||
|
||||
function isPermissionActive (group: ModulePermissionGroup, permissionId: Ref<Permission>): boolean {
|
||||
return !getDisabledPermissions(group).has(permissionId)
|
||||
}
|
||||
|
||||
async function togglePermission (
|
||||
group: ModulePermissionGroup,
|
||||
permissionId: Ref<Permission>,
|
||||
enabled: boolean
|
||||
): Promise<void> {
|
||||
if (!isModuleEnabled(group)) return
|
||||
const disabled = getDisabledPermissions(group)
|
||||
if (enabled) {
|
||||
disabled.delete(permissionId)
|
||||
} else {
|
||||
disabled.add(permissionId)
|
||||
}
|
||||
await client.updateDoc(core.class.ModulePermissionGroup, core.space.Model, group._id, {
|
||||
disabledPermissions: Array.from(disabled)
|
||||
} as any)
|
||||
}
|
||||
|
||||
async function toggleModule (group: ModulePermissionGroup, enabled: boolean): Promise<void> {
|
||||
await client.updateDoc(core.class.ModulePermissionGroup, core.space.Model, group._id, {
|
||||
enabled
|
||||
} as any)
|
||||
}
|
||||
|
||||
function isModuleEnabled (group: ModulePermissionGroup): boolean {
|
||||
return group.enabled ?? true
|
||||
}
|
||||
|
||||
function getPermissionLabel (permissionId: Ref<Permission>): IntlString {
|
||||
return permissionsMap.get(permissionId)?.label ?? getEmbeddedLabel(permissionId)
|
||||
}
|
||||
|
||||
function onAccessToggle (group: ModulePermissionGroup, ev: Event): void {
|
||||
const e = ev as CustomEvent<boolean>
|
||||
void toggleModule(group, e.detail)
|
||||
}
|
||||
|
||||
function onPermissionToggle (group: ModulePermissionGroup, permissionId: Ref<Permission>, ev: Event): void {
|
||||
const e = ev as CustomEvent<boolean>
|
||||
void togglePermission(group, permissionId, e.detail)
|
||||
}
|
||||
|
||||
function handleAccessToggle (group: ModulePermissionGroup): (ev: Event) => void {
|
||||
return (ev: Event) => {
|
||||
onAccessToggle(group, ev)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePermissionToggle (group: ModulePermissionGroup, permissionId: Ref<Permission>): (ev: Event) => void {
|
||||
return (ev: Event) => {
|
||||
onPermissionToggle(group, permissionId, ev)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="hulyComponent">
|
||||
<Header adaptive={'disabled'}>
|
||||
<Breadcrumb label={setting.string.GuestPermissionsSettings} size={'large'} isCurrent />
|
||||
</Header>
|
||||
<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 guestPermissionsRoot flex-col">
|
||||
<section class="section">
|
||||
<div class="sectionHeader">
|
||||
<div class="sectionTitle">
|
||||
<Label label={setting.string.GuestPermissionsModulePermissions} />
|
||||
</div>
|
||||
<div class="sectionHint">
|
||||
<Label label={setting.string.GuestPermissionsModulePermissionsHint} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cardStack">
|
||||
{#each sortedVisibleModuleGroups as group}
|
||||
{@const app = getApplication(group.application)}
|
||||
{@const moduleOn = isModuleEnabled(group)}
|
||||
{@const permissionCount = (group.permissions ?? []).length}
|
||||
<div class="permissionModuleCard" class:permissionModuleCard-off={!moduleOn}>
|
||||
<div
|
||||
class="permissionModuleCard-header"
|
||||
class:permissionModuleCard-headerOnly={permissionCount === 0}
|
||||
>
|
||||
<div class="permissionModuleCard-headerMain">
|
||||
{#if app}
|
||||
<div class="appIcon appIcon-sm">
|
||||
<Icon icon={app.icon} size={'small'} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="appIcon appIcon-sm appIcon-placeholder" />
|
||||
{/if}
|
||||
<div class="permissionModuleCard-titles">
|
||||
<div class="permissionModuleCard-name">
|
||||
<Label label={getApplicationLabel(group.application)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="permissionModuleCard-toggleCell">
|
||||
<Toggle on={moduleOn} on:change={handleAccessToggle(group)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if permissionCount > 0}
|
||||
<div class="permissionRows">
|
||||
{#each group.permissions ?? [] as permissionId}
|
||||
<div class="permissionRow">
|
||||
<div class="permissionRow-label">
|
||||
<Label label={getPermissionLabel(permissionId)} />
|
||||
</div>
|
||||
<div class="permissionRow-toggleCell">
|
||||
<Toggle
|
||||
disabled={!moduleOn}
|
||||
on={isPermissionActive(group, permissionId)}
|
||||
on:change={handlePermissionToggle(group, permissionId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if visibleModuleGroups.length === 0}
|
||||
<div class="emptyState emptyState-block">—</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Scroller>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.guestPermissionsRoot {
|
||||
max-width: 40rem;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
|
||||
.sectionHint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--theme-halfcontent-color);
|
||||
}
|
||||
|
||||
/* Matches packages/ui Toggle width for a single aligned column */
|
||||
$toggleTrackWidth: 2.25rem;
|
||||
|
||||
.cardStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.appIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--small-focus-BorderRadius);
|
||||
background-color: var(--theme-button-default);
|
||||
color: var(--theme-caption-color);
|
||||
}
|
||||
|
||||
.appIcon-sm {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.appIcon-placeholder {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.permissionModuleCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--small-focus-BorderRadius);
|
||||
border: 1px solid var(--theme-navpanel-divider);
|
||||
overflow: hidden;
|
||||
background-color: var(--theme-panel-color);
|
||||
box-shadow: var(--theme-popup-shadow);
|
||||
}
|
||||
|
||||
.permissionModuleCard-off .permissionRows {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.permissionModuleCard-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) #{$toggleTrackWidth};
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--theme-comp-header-color);
|
||||
border-bottom: 1px solid var(--theme-divider-color);
|
||||
|
||||
&.permissionModuleCard-headerOnly {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.permissionModuleCard-headerMain {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permissionModuleCard-toggleCell,
|
||||
.permissionRow-toggleCell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: $toggleTrackWidth;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.permissionModuleCard-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
|
||||
.permissionModuleCard-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--theme-halfcontent-color);
|
||||
}
|
||||
|
||||
.permissionRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.25rem 1rem 0.75rem;
|
||||
background-color: var(--theme-panel-color);
|
||||
}
|
||||
|
||||
.permissionRow {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) #{$toggleTrackWidth};
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
padding: 0.625rem 0 0.625rem 0.25rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.permissionRow:not(:first-child) {
|
||||
border-top: 1px solid var(--theme-navpanel-divider);
|
||||
}
|
||||
|
||||
.permissionRow-label {
|
||||
min-width: 0;
|
||||
color: var(--theme-content-color);
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
font-size: 0.875rem;
|
||||
color: var(--theme-halfcontent-color);
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.emptyState-block {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
</style>
|
||||
@@ -72,6 +72,7 @@ import EditRelation from './components/EditRelation.svelte'
|
||||
import AddSocialId from './components/socialIds/AddSocialId.svelte'
|
||||
import AddEmailSocialId from './components/socialIds/AddEmailSocialId.svelte'
|
||||
import Mailboxes from './components/Mailboxes.svelte'
|
||||
import GuestPermissionsSettings from './components/GuestPermissionsSettings.svelte'
|
||||
import OfficeSettings from './components/OfficeSettings.svelte'
|
||||
import BaseIntegrationState from './components/integrations/BaseIntegrationState.svelte'
|
||||
import IntegrationStateRow from './components/integrations/IntegrationStateRow.svelte'
|
||||
@@ -164,6 +165,7 @@ export default async (): Promise<Resources> => ({
|
||||
CreateRelation,
|
||||
EditRelation,
|
||||
Mailboxes,
|
||||
GuestPermissionsSettings,
|
||||
OfficeSettings,
|
||||
AddSocialId,
|
||||
AddEmailSocialId,
|
||||
|
||||
@@ -30,7 +30,8 @@ export default mergeIds(settingId, setting, {
|
||||
ManageSpaceTypesTools: '' as AnyComponent,
|
||||
ManageSpaceTypeContent: '' as AnyComponent,
|
||||
Spaces: '' as AnyComponent,
|
||||
AddSocialId: '' as AnyComponent
|
||||
AddSocialId: '' as AnyComponent,
|
||||
GuestPermissionsSettings: '' as AnyComponent
|
||||
},
|
||||
string: {
|
||||
IntegrationDisabled: '' as IntlString,
|
||||
|
||||
@@ -311,6 +311,9 @@ export default plugin(settingId, {
|
||||
OfficeDefaultSettings: '' as IntlString,
|
||||
DefaultStartWithTranscription: '' as IntlString,
|
||||
DefaultStartWithRecording: '' as IntlString,
|
||||
GuestPermissionsSettings: '' as IntlString,
|
||||
GuestPermissionsModulePermissions: '' as IntlString,
|
||||
GuestPermissionsModulePermissionsHint: '' as IntlString,
|
||||
MailboxErrorInvalidName: '' as IntlString,
|
||||
MailboxErrorDomainNotFound: '' as IntlString,
|
||||
MailboxErrorNameRulesViolated: '' as IntlString,
|
||||
@@ -352,6 +355,7 @@ export default plugin(settingId, {
|
||||
icon: {
|
||||
AccountSettings: '' as Asset,
|
||||
Owners: '' as Asset,
|
||||
Members: '' as Asset,
|
||||
Password: '' as Asset,
|
||||
Setting: '' as Asset,
|
||||
Integrations: '' as Asset,
|
||||
|
||||
@@ -44,6 +44,7 @@ const mermaidMetaTxField = 'mermaid-meta-tx'
|
||||
|
||||
interface TxMetaContainer {
|
||||
nodePatch?: NodePatchSpec
|
||||
nodePatches?: NodePatchSpec[]
|
||||
renderResult?: MermaidRenderResult
|
||||
updateDecorations?: boolean
|
||||
}
|
||||
@@ -122,7 +123,7 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
|
||||
|
||||
addProseMirrorPlugins () {
|
||||
const parent = (this.parent?.() ?? []).filter((p) => p.props.handlePaste === undefined)
|
||||
return [...parent, MermaidDecorator(this.options)]
|
||||
return [...parent, MermaidCodeBlockNormalizer(), MermaidDecorator(this.options)]
|
||||
},
|
||||
|
||||
addNodeView () {
|
||||
@@ -358,6 +359,75 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Normalizes pasted/imported content so that Mermaid blocks render.
|
||||
*
|
||||
* There are multiple ways Mermaid content can enter the editor (markdown paste, html paste,
|
||||
* programmatic inserts). Some of those paths produce a regular `codeBlock` with
|
||||
* `attrs.language === 'mermaid'` instead of a dedicated `mermaid` node.
|
||||
*
|
||||
* Our rendering pipeline only targets `mermaid` nodes, so we convert such `codeBlock`s
|
||||
* into `mermaid` nodes on any doc-changing transaction.
|
||||
*/
|
||||
function MermaidCodeBlockNormalizer (): Plugin {
|
||||
return new Plugin({
|
||||
key: new PluginKey('mermaid-codeblock-normalizer'),
|
||||
appendTransaction (transactions, oldState, newState) {
|
||||
if (!transactions.some((tr) => tr.docChanged)) return
|
||||
|
||||
const { schema } = newState
|
||||
const mermaidType = schema.nodes[MermaidExtension.name]
|
||||
const codeBlockType = schema.nodes.codeBlock
|
||||
|
||||
if (mermaidType == null || codeBlockType == null) return
|
||||
|
||||
const targets: Array<{ pos: number, node: ProseMirrorNode }> = []
|
||||
newState.doc.descendants((node, pos) => {
|
||||
if (node.type !== codeBlockType) return
|
||||
if ((node.attrs as any)?.language !== 'mermaid') return
|
||||
targets.push({ pos, node })
|
||||
})
|
||||
|
||||
if (targets.length === 0) return
|
||||
|
||||
// Replace from end to start to keep positions stable.
|
||||
const tr = newState.tr
|
||||
const selectionPos = newState.selection.from
|
||||
const nodePatches: NodePatchSpec[] = []
|
||||
let shouldMoveSelection = false
|
||||
let selectionTargetPos = 0
|
||||
for (let i = targets.length - 1; i >= 0; i--) {
|
||||
const { pos, node } = targets[i]
|
||||
const attrs = { ...(node.attrs ?? {}), language: 'mermaid' }
|
||||
tr.replaceRangeWith(pos, pos + node.nodeSize, mermaidType.create(attrs, node.content, node.marks))
|
||||
|
||||
// If the user selection is inside the normalized block, keep it editable (unfolded)
|
||||
// and keep the cursor inside the new node.
|
||||
if (selectionPos >= pos && selectionPos <= pos + node.nodeSize) {
|
||||
nodePatches.push({ pos, folded: false, selected: false })
|
||||
shouldMoveSelection = true
|
||||
selectionTargetPos = pos
|
||||
}
|
||||
}
|
||||
|
||||
if (nodePatches.length > 0) {
|
||||
setTxMeta(tr, { nodePatches })
|
||||
}
|
||||
|
||||
if (shouldMoveSelection) {
|
||||
// Place the cursor into the code content of the mermaid node.
|
||||
// Node content starts at `pos + 1`.
|
||||
const nextSelection =
|
||||
TextSelection.findFrom(tr.doc.resolve(selectionTargetPos + 1), 1) ??
|
||||
TextSelection.create(tr.doc, selectionTargetPos + 1)
|
||||
tr.setSelection(nextSelection)
|
||||
}
|
||||
|
||||
return tr
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface MermaidPluginState {
|
||||
decorationSet: DecorationSet
|
||||
decorationCache: Map<number | string, NodeDecorationState>
|
||||
@@ -554,6 +624,11 @@ function buildState (
|
||||
const lastDecorationSet = tr !== undefined ? prev.decorationSet.map(tr.mapping, tr.doc) : prev.decorationSet
|
||||
|
||||
const nodeStatePatch = getTxMeta(tr)?.nodePatch
|
||||
const nodeStatePatches = getTxMeta(tr)?.nodePatches
|
||||
const nodeStatePatchByPos =
|
||||
nodeStatePatches !== undefined && nodeStatePatches.length > 0
|
||||
? new Map<number, NodePatchSpec>(nodeStatePatches.map((p) => [p.pos, p]))
|
||||
: undefined
|
||||
|
||||
let mIndex = 0
|
||||
doc.descendants((node, pos, parent, index) => {
|
||||
@@ -584,9 +659,12 @@ function buildState (
|
||||
textContent: node.textContent
|
||||
}
|
||||
|
||||
if (nodeStatePatch !== undefined && pos === nodeStatePatch.pos) {
|
||||
newState.folded = nodeStatePatch.folded
|
||||
newState.selected = nodeStatePatch.selected
|
||||
const patch =
|
||||
nodeStatePatchByPos?.get(pos) ??
|
||||
(nodeStatePatch !== undefined && pos === nodeStatePatch.pos ? nodeStatePatch : undefined)
|
||||
if (patch !== undefined) {
|
||||
newState.folded = patch.folded
|
||||
newState.selected = patch.selected
|
||||
}
|
||||
|
||||
if (yid !== undefined) decorationCache.set(yid, newState)
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// 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 { Schema } from '@tiptap/pm/model'
|
||||
import { EditorState, NodeSelection, TextSelection } from '@tiptap/pm/state'
|
||||
|
||||
import { PasteTextAsMarkdownPlugin } from '../smartPaste'
|
||||
|
||||
jest.mock('@hcengineering/text', () => ({
|
||||
__esModule: true,
|
||||
MarkupNodeType: {
|
||||
doc: 'doc',
|
||||
text: 'text',
|
||||
paragraph: 'paragraph',
|
||||
heading: 'heading',
|
||||
code_block: 'codeBlock',
|
||||
bullet_list: 'bulletList',
|
||||
list_item: 'listItem',
|
||||
table: 'table',
|
||||
todoList: 'todoList',
|
||||
ordered_list: 'orderedList',
|
||||
reference: 'reference',
|
||||
image: 'image',
|
||||
mermaid: 'mermaid'
|
||||
},
|
||||
MarkupMarkType: {
|
||||
bold: 'bold',
|
||||
em: 'em',
|
||||
code: 'code',
|
||||
link: 'link'
|
||||
}
|
||||
}))
|
||||
|
||||
jest.mock('@hcengineering/text-markdown', () => ({
|
||||
__esModule: true,
|
||||
markdownToMarkup: (markdown: string) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: markdown.trim().length > 0 ? markdown.trim() : 'title' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
}))
|
||||
|
||||
jest.mock('../../codeSnippets/codeblock', () => ({
|
||||
__esModule: true,
|
||||
CodeBlockHighlighExtension: { name: 'codeBlock' }
|
||||
}))
|
||||
|
||||
function makeSchema (): Schema {
|
||||
return new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
text: { group: 'inline' },
|
||||
heading: {
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
attrs: { level: { default: 1 } },
|
||||
toDOM: (node) => ['h' + node.attrs.level, 0],
|
||||
parseDOM: [
|
||||
{ tag: 'h1', attrs: { level: 1 } },
|
||||
{ tag: 'h2', attrs: { level: 2 } },
|
||||
{ tag: 'h3', attrs: { level: 3 } }
|
||||
]
|
||||
},
|
||||
paragraph: {
|
||||
group: 'block',
|
||||
content: 'inline*',
|
||||
toDOM: () => ['p', 0],
|
||||
parseDOM: [{ tag: 'p' }]
|
||||
},
|
||||
codeBlock: {
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
attrs: { language: { default: null } },
|
||||
toDOM: () => ['pre', ['code', 0]],
|
||||
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }]
|
||||
},
|
||||
mermaid: {
|
||||
group: 'block',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
attrs: { language: { default: 'mermaid' } },
|
||||
toDOM: () => ['div', { class: 'mermaid-diagram' }, ['code', 0]],
|
||||
parseDOM: [{ tag: 'div.mermaid-diagram', preserveWhitespace: 'full' }]
|
||||
}
|
||||
},
|
||||
marks: {}
|
||||
})
|
||||
}
|
||||
|
||||
function makeClipboardData (data: { plain?: string, markdown?: string, types?: string[] }): any {
|
||||
const plain = data.plain ?? ''
|
||||
const markdown = data.markdown ?? ''
|
||||
const types = data.types ?? ['text/plain']
|
||||
return {
|
||||
types,
|
||||
getData: (t: string) => {
|
||||
if (t === 'text/plain') return plain
|
||||
if (t === 'text/markdown') return markdown
|
||||
return ''
|
||||
}
|
||||
} as any
|
||||
}
|
||||
|
||||
describe('SmartPaste handlePaste ignore contexts', () => {
|
||||
it('ignores smart paste when selection is inside a codeBlock', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [
|
||||
schema.node('codeBlock', { language: 'mermaid' }, schema.text('graph TD\nA-->B'))
|
||||
])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores smart paste when selection is inside a mermaid block', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('graph TD\nA-->B'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores smart paste when NodeSelection is a codeBlock', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('codeBlock', undefined, schema.text('x'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: NodeSelection.create(doc, 0)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores smart paste when NodeSelection is a mermaid node', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('mermaid', undefined, schema.text('x'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: NodeSelection.create(doc, 0)
|
||||
})
|
||||
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch: jest.fn() },
|
||||
{ clipboardData: makeClipboardData({ plain: '# title' }) },
|
||||
null
|
||||
)
|
||||
expect(handled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SmartPaste handlePaste transform scenarios', () => {
|
||||
it('transforms plain text paste into markdown output in normal text selection', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const dispatch = jest.fn()
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch },
|
||||
{ clipboardData: makeClipboardData({ plain: '# Title', types: ['text/plain'] }) },
|
||||
null
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('transforms when explicit markdown is present even with rich clipboard types', () => {
|
||||
const schema = makeSchema()
|
||||
const doc = schema.node('doc', undefined, [schema.node('paragraph', undefined, schema.text('hello'))])
|
||||
const state = EditorState.create({
|
||||
schema,
|
||||
doc,
|
||||
selection: TextSelection.create(doc, 2)
|
||||
})
|
||||
|
||||
const dispatch = jest.fn()
|
||||
const plugin = PasteTextAsMarkdownPlugin()
|
||||
const handled = (plugin.props as any).handlePaste(
|
||||
{ state, dispatch },
|
||||
{
|
||||
clipboardData: makeClipboardData({
|
||||
plain: 'fallback',
|
||||
markdown: '## Heading',
|
||||
types: ['text/html', 'text/markdown']
|
||||
})
|
||||
},
|
||||
null
|
||||
)
|
||||
|
||||
expect(handled).toBe(true)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ import { MarkupMarkType, MarkupNodeType, type MarkupNode } from '@hcengineering/
|
||||
import { markdownToMarkup } from '@hcengineering/text-markdown'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Node, type Schema } from '@tiptap/pm/model'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { NodeSelection, Plugin } from '@tiptap/pm/state'
|
||||
import { CodeBlockHighlighExtension } from '../codeSnippets/codeblock'
|
||||
import { hasTableMetadataMarker } from './tableMetadata'
|
||||
|
||||
@@ -29,7 +29,7 @@ export const SmartPasteExtension = Extension.create({
|
||||
}
|
||||
})
|
||||
|
||||
function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
export function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
handlePaste (view, event, slice) {
|
||||
@@ -39,12 +39,16 @@ function PasteTextAsMarkdownPlugin (): Plugin {
|
||||
const pastedText = clipboardData.getData('text/plain')
|
||||
const pastedMarkdown = clipboardData.getData('text/markdown')
|
||||
|
||||
// check if we are in code block
|
||||
const { $from } = view.state.selection
|
||||
// Ignore smart paste inside code blocks / mermaid blocks (keep default paste behavior).
|
||||
const selection = view.state.selection
|
||||
const ignoredNodeTypes = new Set<string>([CodeBlockHighlighExtension.name, 'mermaid'])
|
||||
if (selection instanceof NodeSelection && ignoredNodeTypes.has(selection.node.type.name)) {
|
||||
return false
|
||||
}
|
||||
const { $from } = selection
|
||||
for (let d = $from.depth; d > 0; d--) {
|
||||
const node = $from.node(d)
|
||||
if (node.type.name === CodeBlockHighlighExtension.name) {
|
||||
// paste as plain text in code blocks
|
||||
if (ignoredNodeTypes.has(node.type.name)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +279,8 @@
|
||||
"Extensions": "Rozšíření",
|
||||
"UnsetParentIssue": "Odebrat nadřazený úkol",
|
||||
"ForbidCreateProjectPermission": "Zakázat vytvoření projektu",
|
||||
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty"
|
||||
"ForbidCreateProjectPermissionDescription": "Zakazuje uživatelům vytvářet nové projekty",
|
||||
"AllowCreatingIssues": "Povolit vytváření úkolů"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"Extensions": "Erweiterungen",
|
||||
"UnsetParentIssue": "Übergeordnete Aufgabe entfernen",
|
||||
"ForbidCreateProjectPermission": "Projekterstellung verbieten",
|
||||
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte"
|
||||
"ForbidCreateProjectPermissionDescription": "Verbietet Benutzern das Erstellen neuer Projekte",
|
||||
"AllowCreatingIssues": "Erstellen von Aufgaben erlauben"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"Extensions": "Extensions",
|
||||
"UnsetParentIssue": "Unset parent issue",
|
||||
"ForbidCreateProjectPermission": "Forbid create project",
|
||||
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects"
|
||||
"ForbidCreateProjectPermissionDescription": "Forbid users creating new projects",
|
||||
"AllowCreatingIssues": "Allow creating issues"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,8 @@
|
||||
"Extensions": "Extensions",
|
||||
"UnsetParentIssue": "Unset parent issue",
|
||||
"ForbidCreateProjectPermission": "Prohibir crear proyecto",
|
||||
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos"
|
||||
"ForbidCreateProjectPermissionDescription": "Prohíbe a los usuarios crear nuevos proyectos",
|
||||
"AllowCreatingIssues": "Permitir crear incidencias"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,8 @@
|
||||
"Extensions": "Extensions",
|
||||
"UnsetParentIssue": "Désélectionner l'issue parent",
|
||||
"ForbidCreateProjectPermission": "Interdire la création de projet",
|
||||
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets"
|
||||
"ForbidCreateProjectPermissionDescription": "Interdit aux utilisateurs de créer de nouveaux projets",
|
||||
"AllowCreatingIssues": "Autoriser la création d'issues"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,8 @@
|
||||
"Extensions": "Estensioni",
|
||||
"UnsetParentIssue": "Annulla l'issue genitore",
|
||||
"ForbidCreateProjectPermission": "Vieta creazione progetto",
|
||||
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti"
|
||||
"ForbidCreateProjectPermissionDescription": "Vieta agli utenti di creare nuovi progetti",
|
||||
"AllowCreatingIssues": "Consenti la creazione di issue"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,8 @@
|
||||
"Extensions": "拡張機能",
|
||||
"UnsetParentIssue": "親イシューの設定を解除",
|
||||
"ForbidCreateProjectPermission": "プロジェクト作成禁止",
|
||||
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します"
|
||||
"ForbidCreateProjectPermissionDescription": "ユーザーが新しいプロジェクトを作成することを禁止します",
|
||||
"AllowCreatingIssues": "イシューの作成を許可"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,8 @@
|
||||
"Extensions": "Extensions",
|
||||
"UnsetParentIssue": "Desmarcar problema pai",
|
||||
"ForbidCreateProjectPermission": "Proibir criação de projeto",
|
||||
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos"
|
||||
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
|
||||
"AllowCreatingIssues": "Permitir criar problemas"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,8 @@
|
||||
"Extensions": "Extensions",
|
||||
"UnsetParentIssue": "Desmarcar problema pai",
|
||||
"ForbidCreateProjectPermission": "Proibir criação de projeto",
|
||||
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos"
|
||||
"ForbidCreateProjectPermissionDescription": "Proíbe os usuários de criar novos projetos",
|
||||
"AllowCreatingIssues": "Permitir criar problemas"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"Extensions": "Дополнительно",
|
||||
"UnsetParentIssue": "Снять родительскую задачу",
|
||||
"ForbidCreateProjectPermission": "Запретить создание проекта",
|
||||
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты"
|
||||
"ForbidCreateProjectPermissionDescription": "Запрещает пользователям создавать новые проекты",
|
||||
"AllowCreatingIssues": "Разрешить создание задач"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,8 @@
|
||||
"DefaultIssueStatus": "Varsayılan sorun durumu",
|
||||
"IssueStatus": "Durum",
|
||||
"Extensions": "Uzantılar",
|
||||
"UnsetParentIssue": "Üst sorunu kaldır"
|
||||
"UnsetParentIssue": "Üst sorunu kaldır",
|
||||
"AllowCreatingIssues": "Sorun oluşturmaya izin ver"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
@@ -289,7 +289,8 @@
|
||||
"Extensions": "扩展",
|
||||
"UnsetParentIssue": "取消父问题",
|
||||
"ForbidCreateProjectPermission": "禁止创建项目",
|
||||
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目"
|
||||
"ForbidCreateProjectPermissionDescription": "禁止用户创建新项目",
|
||||
"AllowCreatingIssues": "允许创建问题"
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user