diff --git a/.gitignore b/.gitignore index 7e45a86eeb..1ec48e0f2d 100644 --- a/.gitignore +++ b/.gitignore @@ -113,4 +113,5 @@ dev/tool/history.json /combined_dependencies .tmp ws-tests/docker-compose.override.yml -.cursor/* \ No newline at end of file +.cursor/* +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index 1d2b32ba43..687f148f55 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ For detailed information about the platform architecture, services, and their in - [Docker](https://docs.docker.com/get-docker/) - [Docker Compose](https://docs.docker.com/compose/install/) +If you use `nvm`, run this after entering the repo to align your shell with the repository Node version: + +```bash +nvm use +``` + ## Verification To verify the installation, perform the following checks in your terminal: diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 928464ae94..57a469ad1f 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -10446,6 +10446,9 @@ importers: '@hcengineering/server-notification': specifier: workspace:^0.7.0 version: link:../../server-plugins/notification + '@hcengineering/view': + specifier: workspace:^0.7.0 + version: link:../../plugins/view devDependencies: '@hcengineering/platform-rig': specifier: workspace:^0.7.21 @@ -40215,6 +40218,115 @@ importers: specifier: ^5.9.3 version: 5.9.3 + ../../services/notification/pod-events-processor: + dependencies: + '@hcengineering/account-client': + specifier: workspace:^0.7.25 + version: link:../../../foundations/core/packages/account-client + '@hcengineering/analytics': + specifier: workspace:^0.7.19 + version: link:../../../foundations/core/packages/analytics + '@hcengineering/analytics-service': + specifier: workspace:^0.7.19 + version: link:../../../foundations/core/packages/analytics-service + '@hcengineering/api-client': + specifier: workspace:^0.7.25 + version: link:../../../foundations/core/packages/api-client + '@hcengineering/calendar': + specifier: workspace:^0.7.0 + version: link:../../../plugins/calendar + '@hcengineering/contact': + specifier: workspace:^0.7.0 + version: link:../../../plugins/contact + '@hcengineering/core': + specifier: workspace:^0.7.26 + version: link:../../../foundations/core/packages/core + '@hcengineering/kafka': + specifier: workspace:^0.7.18 + version: link:../../../foundations/server/packages/kafka + '@hcengineering/notification': + specifier: workspace:^0.7.0 + version: link:../../../plugins/notification + '@hcengineering/platform': + specifier: workspace:^0.7.20 + version: link:../../../foundations/core/packages/platform + '@hcengineering/server-client': + specifier: workspace:^0.7.16 + version: link:../../../foundations/server/packages/client + '@hcengineering/server-core': + specifier: workspace:^0.7.19 + version: link:../../../foundations/server/packages/core + '@hcengineering/server-token': + specifier: workspace:^0.7.18 + version: link:../../../foundations/core/packages/token + '@hcengineering/text-core': + specifier: workspace:^0.7.19 + version: link:../../../foundations/core/packages/text-core + '@hcengineering/time': + specifier: workspace:^0.7.0 + version: link:../../../plugins/time + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + devDependencies: + '@hcengineering/platform-rig': + specifier: workspace:^0.7.21 + version: link:../../../foundations/utils/packages/platform-rig + '@tsconfig/node16': + specifier: ^1.0.4 + version: 1.0.4 + '@types/jest': + specifier: ^29.5.5 + version: 29.5.14 + '@types/node': + specifier: ^22.18.1 + version: 22.19.0 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@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 + eslint: + specifier: ^8.54.0 + version: 8.57.1 + eslint-config-standard-with-typescript: + specifier: ^40.0.0 + version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3) + eslint-plugin-import: + specifier: ^2.26.0 + version: 2.32.0(eslint@8.57.1) + eslint-plugin-n: + specifier: ^15.4.0 + version: 15.7.0(eslint@8.57.1) + eslint-plugin-node: + specifier: ^11.1.0 + version: 11.1.0(eslint@8.57.1) + eslint-plugin-promise: + specifier: ^6.1.1 + version: 6.6.0(eslint@8.57.1) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)) + prettier: + specifier: ^3.6.2 + version: 3.6.2 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@22.19.0)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + ../../services/notification/pod-notification: dependencies: '@hcengineering/analytics': diff --git a/common/scripts/docker.sh b/common/scripts/docker.sh index 3f6de5a828..aa3326de40 100755 --- a/common/scripts/docker.sh +++ b/common/scripts/docker.sh @@ -58,5 +58,6 @@ else --to @hcengineering/pod-process \ --to @hcengineering/pod-rating \ --to @hcengineering/pod-payment \ - --to @hcengineering/pod-worker + --to @hcengineering/pod-worker \ + --to @hcengineering/pod-events-processor fi diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index bc4429f703..2774fcd71a 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -595,6 +595,24 @@ services: - QUEUE_CONFIG=${QUEUE_CONFIG} - QUEUE_REGION=cockroach restart: unless-stopped + events-processor: + image: hardcoreeng/events-processor + extra_hosts: + - 'huly.local:host-gateway' + depends_on: + redpanda: + condition: service_started + account: + condition: service_started + environment: + - SERVICE_ID=events-processor + - LOG_LEVEL=debug + - SECRET=secret + - ACCOUNTS_URL=http://huly.local:3000 + - QUEUE_CONFIG=${QUEUE_CONFIG} + - QUEUE_REGION=cockroach + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces + restart: unless-stopped # translate: # image: hardcoreeng/translate # extra_hosts: diff --git a/foundations/core/packages/analytics-service/src/logging.ts b/foundations/core/packages/analytics-service/src/logging.ts index 2ccf0db338..c9f45f5be9 100644 --- a/foundations/core/packages/analytics-service/src/logging.ts +++ b/foundations/core/packages/analytics-service/src/logging.ts @@ -16,7 +16,7 @@ export class SplitLogger implements MeasureLogger { const rootDir = this.opts.root ?? 'logs' this.logger = winston.createLogger({ - level: 'info', + level: process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info', exitOnError: false }) const errorPrinter = ({ message, stack, ...rest }: Error): object => ({ @@ -99,6 +99,13 @@ export class SplitLogger implements MeasureLogger { this.logger.warn({ message, ...obj }) } + debug (message: string, obj?: Record): void { + if (this.opts.parent !== undefined) { + this.opts.parent.debug({ message, ...obj }) + } + this.logger.debug({ message, ...obj }) + } + logOperation (operation: string, time: number, params: ParamsType): void { this.logger.info(operation, { time, ...params }) } diff --git a/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts index 660147942f..4126458230 100644 --- a/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts +++ b/foundations/core/packages/measurements-otlp/src/__tests__/telemetry.test.ts @@ -22,6 +22,7 @@ describe('telemetry', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } diff --git a/foundations/core/packages/measurements-otlp/src/telemetry.ts b/foundations/core/packages/measurements-otlp/src/telemetry.ts index c1770a01a3..a3df6a2193 100644 --- a/foundations/core/packages/measurements-otlp/src/telemetry.ts +++ b/foundations/core/packages/measurements-otlp/src/telemetry.ts @@ -11,6 +11,7 @@ import { updateMeasure, type FullParamsType, type MeasureLogger, + type MeasureLogLevel, type Metrics, type ParamsType, type WithOptions @@ -97,7 +98,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext { readonly logParams?: ParamsType, readonly otlpLogger?: Logger, - readonly meter?: MetricsContext + readonly meter?: MetricsContext, + readonly logLevel: MeasureLogLevel = 'info' ) { this.name = name this.params = params @@ -133,6 +135,7 @@ export class OpenTelemetryMetricsContext implements MeasureContext { logger?: MeasureLogger span?: WithOptions['span'] // By default true meta?: Record + logLevel?: MeasureLogLevel } ): MeasureContext { let _span: Span | undefined @@ -170,7 +173,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext { this, this.logParams, this.otlpLogger, - this.meter + this.meter, + opt?.logLevel ?? this.logLevel ) result.id = this.id result.contextData = this.contextData @@ -309,6 +313,23 @@ export class OpenTelemetryMetricsContext implements MeasureContext { this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) } + debug (message: string, args?: Record): void { + if (this.logLevel !== 'debug') return + if (this.otlpLogger !== undefined) { + this.otlpLogger.emit({ + severityNumber: SeverityNumber.DEBUG, + severityText: 'debug', + context: this.context, + body: message, + attributes: { + 'service.name': sdkServiceName, + ...(args ?? {}) + } + }) + } + this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + end (): void { this.done() } @@ -530,7 +551,8 @@ export function createOpenTelemetryMetricsContext ( ): MeasureContext { if (!initOpenTelemetrySDK(name, version ?? '')) { console.warn('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not set, OpenTelemetry metrics will not be sent') - return new MeasureMetricsContext(name, params, fullParams, metrics, logger) + const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info' + return new MeasureMetricsContext(name, params, fullParams, metrics, logger, undefined, undefined, rootLogLevel) } // Traces @@ -542,6 +564,8 @@ export function createOpenTelemetryMetricsContext ( const meter = otelMetrics.getMeter(name, version) + const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info' + const ctx = new OpenTelemetryMetricsContext( name, tracer, @@ -554,7 +578,8 @@ export function createOpenTelemetryMetricsContext ( undefined, undefined, otlpLogger, - new MetricsContext(meter) + new MetricsContext(meter), + rootLogLevel ) return ctx } diff --git a/foundations/core/packages/measurements/src/__tests__/context.test.ts b/foundations/core/packages/measurements/src/__tests__/context.test.ts index d59d0a4945..818ecbd616 100644 --- a/foundations/core/packages/measurements/src/__tests__/context.test.ts +++ b/foundations/core/packages/measurements/src/__tests__/context.test.ts @@ -21,6 +21,7 @@ describe('context', () => { expect(typeof logger.info).toBe('function') expect(typeof logger.error).toBe('function') expect(typeof logger.warn).toBe('function') + expect(typeof logger.debug).toBe('function') expect(typeof logger.close).toBe('function') }) @@ -54,6 +55,16 @@ describe('context', () => { consoleSpy.mockRestore() }) + it('should log debug messages', () => { + const consoleSpy = jest.spyOn(console, 'debug').mockImplementation() + const logger = consoleLogger({ service: 'test' }) + + logger.debug('Debug message', { detail: 'x' }) + + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + it('should handle errors in params', () => { const consoleSpy = jest.spyOn(console, 'error').mockImplementation() const logger = consoleLogger({}) @@ -145,6 +156,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } @@ -163,6 +175,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } @@ -181,6 +194,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } @@ -194,6 +208,47 @@ describe('context', () => { ) }) + it('should not log debug when log level is info', () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger) + ctx.debug('Skip', { k: 1 }) + + expect(mockLogger.debug).not.toHaveBeenCalled() + }) + + it('should log debug when log level is debug', () => { + const mockLogger: MeasureLogger = { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + close: jest.fn(async () => {}), + logOperation: jest.fn() + } + + const ctx = new MeasureMetricsContext( + 'test', + { op: 'test' }, + {}, + newMetrics(), + mockLogger, + undefined, + undefined, + 'debug' + ) + ctx.debug('D', { k: 1 }) + + expect(mockLogger.debug).toHaveBeenCalledWith('D', expect.objectContaining({ k: 1, op: 'test' })) + }) + it('should get params', () => { const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger) const params = ctx.getParams() @@ -440,6 +495,7 @@ describe('context', () => { info: jest.fn(), error: jest.fn(), warn: jest.fn(), + debug: jest.fn(), close: jest.fn(async () => {}), logOperation: jest.fn() } diff --git a/foundations/core/packages/measurements/src/context.ts b/foundations/core/packages/measurements/src/context.ts index 62dbb347cc..9a8688635c 100644 --- a/foundations/core/packages/measurements/src/context.ts +++ b/foundations/core/packages/measurements/src/context.ts @@ -6,6 +6,7 @@ import { type FullParamsType, type MeasureContext, type MeasureLogger, + type MeasureLogLevel, type Metrics, type ParamsType, type OperationLog, @@ -42,6 +43,14 @@ export const consoleLogger = (logParams: Record): MeasureLogger => warn: (msg, args) => { console.warn(msg, ...Object.entries(args ?? {}).map((it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`)) }, + debug: (msg, args) => { + console.debug( + msg, + ...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map( + (it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}` + ) + ) + }, close: async () => {}, logOperation: (operation, time, params) => {} }) @@ -62,6 +71,8 @@ export class MeasureMetricsContext implements MeasureContext { metrics: Metrics id?: string + private readonly logLevel: MeasureLogLevel + st = platformNow() contextData: object = {} private done (value?: number, override?: boolean): void { @@ -75,12 +86,14 @@ export class MeasureMetricsContext implements MeasureContext { metrics: Metrics = newMetrics(), logger?: MeasureLogger, readonly parent?: MeasureContext, - readonly logParams?: ParamsType + readonly logParams?: ParamsType, + logLevel: MeasureLogLevel = 'info' ) { this.name = name this.params = params this.fullParams = fullParams this.metrics = metrics + this.logLevel = logLevel this.metrics.namedParams = this.metrics.namedParams ?? {} for (const [k, v] of Object.entries(params)) { if (this.metrics.namedParams[k] !== v) { @@ -94,7 +107,16 @@ export class MeasureMetricsContext implements MeasureContext { } measure (name: string, value: number, override?: boolean): void { - const c = new MeasureMetricsContext('#' + name, {}, {}, childMetrics(this.metrics, ['#' + name]), this.logger, this) + const c = new MeasureMetricsContext( + '#' + name, + {}, + {}, + childMetrics(this.metrics, ['#' + name]), + this.logger, + this, + undefined, + this.logLevel + ) c.contextData = this.contextData c.done(value, override) } @@ -106,6 +128,8 @@ export class MeasureMetricsContext implements MeasureContext { fullParams?: FullParamsType logger?: MeasureLogger span?: WithOptions['span'] // By default true + meta?: Record + logLevel?: MeasureLogLevel } ): MeasureContext { const result = new MeasureMetricsContext( @@ -115,7 +139,8 @@ export class MeasureMetricsContext implements MeasureContext { childMetrics(this.metrics, [name]), opt?.logger ?? this.logger, this, - this.logParams + this.logParams, + opt?.logLevel ?? this.logLevel ) result.id = this.id result.contextData = this.contextData @@ -187,6 +212,11 @@ export class MeasureMetricsContext implements MeasureContext { this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) } + debug (message: string, args?: Record): void { + if (this.logLevel !== 'debug') return + this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) }) + } + end (): void { this.done() } @@ -202,8 +232,11 @@ export class NoMetricsContext implements MeasureContext { contextData: object = {} - constructor (logger?: MeasureLogger) { + private readonly logLevel: MeasureLogLevel + + constructor (logger?: MeasureLogger, logLevel: MeasureLogLevel = 'info') { this.logger = logger ?? consoleLogger({}) + this.logLevel = logLevel } measure (name: string, value: number, override?: boolean): void {} @@ -211,10 +244,15 @@ export class NoMetricsContext implements MeasureContext { newChild ( name: string, params: ParamsType, - fullParams?: FullParamsType | (() => FullParamsType), - logger?: MeasureLogger + opt?: { + fullParams?: FullParamsType | (() => FullParamsType) + logger?: MeasureLogger + span?: WithOptions['span'] + meta?: Record + logLevel?: MeasureLogLevel + } ): MeasureContext { - const result = new NoMetricsContext(logger ?? this.logger) + const result = new NoMetricsContext(opt?.logger ?? this.logger, opt?.logLevel ?? this.logLevel) result.id = this.id result.contextData = this.contextData return result @@ -226,7 +264,7 @@ export class NoMetricsContext implements MeasureContext { op: (ctx: MeasureContext) => T | Promise, fullParams?: ParamsType | (() => FullParamsType) ): Promise { - const r = op(this.newChild(name, params, fullParams, this.logger)) + const r = op(this.newChild(name, params, { fullParams, logger: this.logger })) return r instanceof Promise ? r : Promise.resolve(r) } @@ -240,7 +278,7 @@ export class NoMetricsContext implements MeasureContext { op: (ctx: MeasureContext) => T, fullParams?: ParamsType | (() => FullParamsType) ): T { - const c = this.newChild(name, params, fullParams, this.logger) + const c = this.newChild(name, params, { fullParams, logger: this.logger }) return op(c) } @@ -250,7 +288,7 @@ export class NoMetricsContext implements MeasureContext { op: (ctx: MeasureContext) => T | Promise, fullParams?: ParamsType ): Promise { - const r = op(this.newChild(name, params, fullParams, this.logger)) + const r = op(this.newChild(name, params, { fullParams, logger: this.logger })) return r instanceof Promise ? r : Promise.resolve(r) } @@ -266,6 +304,11 @@ export class NoMetricsContext implements MeasureContext { this.logger.warn(message, { ...args }) } + debug (message: string, args?: Record): void { + if (this.logLevel !== 'debug') return + this.logger.debug(message, { ...args }) + } + end (): void {} getParams (): ParamsType { diff --git a/foundations/core/packages/measurements/src/types.ts b/foundations/core/packages/measurements/src/types.ts index d2d2e1366b..31b5dc80b1 100644 --- a/foundations/core/packages/measurements/src/types.ts +++ b/foundations/core/packages/measurements/src/types.ts @@ -49,6 +49,12 @@ export interface Metrics extends MetricsData { opLog?: Record } +/** + * Root log verbosity for {@link MeasureContext.debug} (child contexts inherit unless overridden in {@link MeasureContext.newChild}). + * @public + */ +export type MeasureLogLevel = 'info' | 'debug' + /** * @public */ @@ -58,6 +64,8 @@ export interface MeasureLogger { warn: (message: string, obj?: Record) => void + debug: (message: string, obj?: Record) => void + logOperation: (operation: string, time: number, params: ParamsType) => void childLogger?: (name: string, params: Record) => MeasureLogger @@ -94,6 +102,7 @@ export interface MeasureContext { logger?: MeasureLogger span?: WithOptions['span'] // By default true meta?: Record + logLevel?: MeasureLogLevel } ) => MeasureContext @@ -128,6 +137,7 @@ export interface MeasureContext { error: (message: string, obj?: Record) => void info: (message: string, obj?: Record) => void warn: (message: string, obj?: Record) => void + debug: (message: string, obj?: Record) => void // No-op unless this context was created with log level `debug`. // Mark current context as complete // If no value is passed, time difference will be used. diff --git a/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts index fcdfa9099c..13c96e4863 100644 --- a/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts +++ b/foundations/core/packages/storage-client/src/__tests__/datalake-storage.test.ts @@ -57,14 +57,24 @@ describe('DatalakeStorage', () => { expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}`) }) - it('should handle special characters in parameters', () => { + it('should encode special characters in parameters', () => { const workspace = 'test workspace' const file = 'file 123' const filename = 'my document.pdf' const url = storage.getFileUrl(workspace, file, filename) - expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/${filename}`) + expect(url).toBe(`${baseUrl}/blob/test%20workspace/file%20123/my%20document.pdf`) + }) + + it('should encode slash in filename as a single path segment', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'folder/document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/folder%2Fdocument.pdf`) }) it('should handle base URL with trailing slash', () => { diff --git a/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts b/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts index 5c8e80c6ef..fe8f127f98 100644 --- a/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts +++ b/foundations/core/packages/storage-client/src/__tests__/front-storage.test.ts @@ -55,14 +55,24 @@ describe('FrontStorage', () => { expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`) }) - it('should handle special characters in workspace and file names', () => { + it('should encode special characters in workspace, file, and filename', () => { const workspace = 'test workspace' const file = 'file 123' const filename = 'my document.pdf' const url = storage.getFileUrl(workspace, file, filename) - expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`) + expect(url).toBe(`${baseUrl}/test%20workspace/my%20document.pdf?file=file%20123&workspace=test%20workspace`) + }) + + it('should encode slash in filename as a single path segment', () => { + const workspace = 'test-workspace' + const file = 'file-123' + const filename = 'folder/document.pdf' + + const url = storage.getFileUrl(workspace, file, filename) + + expect(url).toBe(`${baseUrl}/${workspace}/folder%2Fdocument.pdf?file=${file}&workspace=${workspace}`) }) it('should handle base URL with trailing slash', () => { diff --git a/foundations/core/packages/storage-client/src/client/datalake.ts b/foundations/core/packages/storage-client/src/client/datalake.ts index ad1dc1f7aa..97ef5884ae 100644 --- a/foundations/core/packages/storage-client/src/client/datalake.ts +++ b/foundations/core/packages/storage-client/src/client/datalake.ts @@ -16,6 +16,7 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadMultipart, uploadXhr } from '../upload' +import { encodePathSegment } from './utils' const getPathname = (url: string): string => { const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' @@ -27,7 +28,12 @@ export class DatalakeStorage implements FileStorage { constructor (private readonly baseUrl: string) {} getFileUrl (workspace: string, file: string, filename?: string): string { - const path = filename !== undefined ? `/blob/${workspace}/${file}/${filename}` : `/blob/${workspace}/${file}` + const encodedWorkspace = encodePathSegment(workspace) + const encodedFile = encodePathSegment(file) + const path = + filename !== undefined + ? `/blob/${encodedWorkspace}/${encodedFile}/${encodePathSegment(filename)}` + : `/blob/${encodedWorkspace}/${encodedFile}` return concatLink(this.baseUrl, path) } diff --git a/foundations/core/packages/storage-client/src/client/front.ts b/foundations/core/packages/storage-client/src/client/front.ts index 54fa4cd0b9..7dfa5d6896 100644 --- a/foundations/core/packages/storage-client/src/client/front.ts +++ b/foundations/core/packages/storage-client/src/client/front.ts @@ -16,6 +16,7 @@ import { concatLink } from '@hcengineering/core' import { FileStorage, FileStorageUploadOptions } from '../types' import { uploadXhr } from '../upload' +import { encodePathSegment } from './utils' const getPathname = (url: string): string => { const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost' @@ -27,7 +28,10 @@ export class FrontStorage implements FileStorage { constructor (private readonly baseUrl: string) {} getFileUrl (workspace: string, file: string, filename?: string): string { - const path = `/${workspace}/${filename ?? file}?file=${file}&workspace=${workspace}` + const encodedWorkspace = encodePathSegment(workspace) + const encodedFile = encodePathSegment(file) + const encodedFilename = encodePathSegment(filename ?? file) + const path = `/${encodedWorkspace}/${encodedFilename}?file=${encodedFile}&workspace=${encodedWorkspace}` return concatLink(this.baseUrl, path) } diff --git a/foundations/core/packages/storage-client/src/client/utils.ts b/foundations/core/packages/storage-client/src/client/utils.ts new file mode 100644 index 0000000000..e6e41b9a6b --- /dev/null +++ b/foundations/core/packages/storage-client/src/client/utils.ts @@ -0,0 +1,18 @@ +// +// 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. +// + +export function encodePathSegment (value: string): string { + return encodeURIComponent(value) +} diff --git a/foundations/server/packages/collaboration/src/__tests__/storage.test.ts b/foundations/server/packages/collaboration/src/__tests__/storage.test.ts index 4a27298913..dbde684129 100644 --- a/foundations/server/packages/collaboration/src/__tests__/storage.test.ts +++ b/foundations/server/packages/collaboration/src/__tests__/storage.test.ts @@ -150,6 +150,7 @@ const mockContext: MeasureContext = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), + debug: jest.fn(), with: jest.fn().mockImplementation((name, params, fn) => fn()), withSync: jest.fn().mockImplementation((name, params, fn) => fn()), measure: jest.fn(), diff --git a/foundations/server/packages/kafka/src/index.ts b/foundations/server/packages/kafka/src/index.ts index 5a88767930..74430845fe 100644 --- a/foundations/server/packages/kafka/src/index.ts +++ b/foundations/server/packages/kafka/src/index.ts @@ -259,9 +259,14 @@ class PlatformQueueConsumerImpl implements ConsumerHandle { maxRetryDelay?: number // Maximum retry delay in seconds (default 10) } ) { + // Long handlers must call ConsumerControl.heartbeat(); these timeouts still help under broker/load jitter (e.g. Redpanda in Docker). + const sessionTimeout = parseInt(process.env.KAFKA_CONSUMER_SESSION_TIMEOUT_MS ?? '90000', 10) this.cc = this.kafka.consumer({ groupId: `${getKafkaTopicId(this.topic, this.config)}-${groupId}`, - allowAutoTopicCreation: true + allowAutoTopicCreation: true, + sessionTimeout, + rebalanceTimeout: Math.min(sessionTimeout * 2, 300000), + heartbeatInterval: 3000 }) void this.start().catch((err) => { diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts index 3fe91ae1ee..887a29048f 100644 --- a/models/calendar/src/index.ts +++ b/models/calendar/src/index.ts @@ -279,17 +279,20 @@ export function createModel (builder: Builder): void { { hidden: false, generated: false, + allowedForAuthor: true, label: calendar.string.Reminder, group: calendar.ids.CalendarNotificationGroup, - txClasses: [], + // Scheduled reminders are created by the events-processor worker, but provider/type settings still expect a + // tx class list. The notification doc itself is materialized via a direct createDoc, not by a tx trigger. + txClasses: [core.class.TxCreateDoc], objectClass: calendar.class.Event, - allowedForAuthor: true, + onlyOwn: true, + defaultEnabled: true, templates: { - textTemplate: 'Reminder: {doc}', - htmlTemplate: 'Reminder: {doc}', - subjectTemplate: 'Reminder: {doc}' - }, - defaultEnabled: false + textTemplate: '{body}', + htmlTemplate: '

{body}

{link}

', + subjectTemplate: '{title}' + } }, calendar.ids.ReminderNotification ) @@ -300,6 +303,12 @@ export function createModel (builder: Builder): void { enabledTypes: [calendar.ids.ReminderNotification] }) + builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, { + provider: notification.providers.PushNotificationProvider, + ignoredTypes: [], + enabledTypes: [calendar.ids.ReminderNotification] + }) + builder.createDoc( activity.class.DocUpdateMessageViewlet, core.space.Model, diff --git a/models/calendar/src/plugin.ts b/models/calendar/src/plugin.ts index c5d02bcc95..b31114a8c7 100644 --- a/models/calendar/src/plugin.ts +++ b/models/calendar/src/plugin.ts @@ -55,7 +55,6 @@ export default mergeIds(calendarId, calendar, { string: { ApplicationLabelCalendar: '' as IntlString, Event: '' as IntlString, - Reminder: '' as IntlString, Shift: '' as IntlString, State: '' as IntlString, CreatedReminder: '' as IntlString, diff --git a/models/server-card/package.json b/models/server-card/package.json index e1888aed2a..afbe444681 100644 --- a/models/server-card/package.json +++ b/models/server-card/package.json @@ -37,6 +37,7 @@ "@hcengineering/core": "workspace:^0.7.26", "@hcengineering/model": "workspace:^0.7.17", "@hcengineering/platform": "workspace:^0.7.20", + "@hcengineering/view": "workspace:^0.7.0", "@hcengineering/card": "workspace:^0.7.0", "@hcengineering/communication": "workspace:^0.7.0", "@hcengineering/server-notification": "workspace:^0.7.0", diff --git a/models/server-card/src/index.ts b/models/server-card/src/index.ts index 32d1be5798..828acd381a 100644 --- a/models/server-card/src/index.ts +++ b/models/server-card/src/index.ts @@ -21,6 +21,7 @@ import serverCard from '@hcengineering/server-card' import card from '@hcengineering/card' import communication from '@hcengineering/communication' import serverNotification from '@hcengineering/server-notification' +import view from '@hcengineering/view' export { serverCardId } from '@hcengineering/server-card' @@ -43,6 +44,16 @@ export function createModel (builder: Builder): void { } }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverCard.trigger.OnViewletUpdate, + isAsync: true, + txMatch: { + _class: core.class.TxUpdateDoc, + objectClass: view.class.Viewlet, + 'operations.config': { $exists: true } + } + }) + builder.createDoc(serverCore.class.Trigger, core.space.Model, { trigger: serverCard.trigger.OnTagRemove, txMatch: { diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index c063d87aa4..52c15d8ec7 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -419,7 +419,7 @@ export function createModel (builder: Builder): void { component: exportPlugin.component.ExportSettings, group: 'settings-editor', feature: 'export', - role: AccountRole.User, + role: AccountRole.Owner, order: 4800 }, setting.ids.Export diff --git a/models/tracker/src/__tests__/migration.test.ts b/models/tracker/src/__tests__/migration.test.ts new file mode 100644 index 0000000000..68f452b241 --- /dev/null +++ b/models/tracker/src/__tests__/migration.test.ts @@ -0,0 +1,49 @@ +// +// 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. +// + +import { DOMAIN_TASK } from '@hcengineering/model-task' +import tracker from '@hcengineering/tracker' + +import { DOMAIN_TRACKER } from '../types' +import { migrateAddStartDate } from '../migration' + +describe('migrateAddStartDate', () => { + it('sets startDate=null on every Issue lacking the field (DOMAIN_TASK)', async () => { + const update = jest.fn().mockResolvedValue(undefined) + const client: any = { update } + + await migrateAddStartDate(client) + + expect(update).toHaveBeenCalledWith( + DOMAIN_TASK, + { _class: tracker.class.Issue, startDate: { $exists: false } }, + { startDate: null } + ) + }) + + it('sets startDate=null on every Milestone lacking the field (DOMAIN_TRACKER)', async () => { + const update = jest.fn().mockResolvedValue(undefined) + const client: any = { update } + + await migrateAddStartDate(client) + + expect(update).toHaveBeenCalledWith( + DOMAIN_TRACKER, + { _class: tracker.class.Milestone, startDate: { $exists: false } }, + { startDate: null } + ) + }) + + it('issues exactly two update calls (one per class)', async () => { + const update = jest.fn().mockResolvedValue(undefined) + const client: any = { update } + + await migrateAddStartDate(client) + + expect(update).toHaveBeenCalledTimes(2) + }) +}) diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 44d1b97b50..baabea1381 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -39,6 +39,7 @@ import { TClassicProjectTypeData, TComponent, TIssue, + TIssueRelation, TIssueStatus, TIssueTemplate, TIssueTypeData, @@ -441,6 +442,7 @@ export function createModel (builder: Builder): void { TProject, TComponent, TIssue, + TIssueRelation, TIssueTemplate, TIssueStatus, TTypeIssuePriority, diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index d07660e56c..a150a2947d 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -47,6 +47,7 @@ import tracker, { } from '@hcengineering/tracker' import { classicIssueTaskStatuses } from '.' +import { DOMAIN_TRACKER } from './types' async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { @@ -170,6 +171,16 @@ async function migrateIdentifiers (client: MigrationClient): Promise { } } +export async function migrateAddStartDate (client: MigrationClient): Promise { + // Issues live in DOMAIN_TASK; Milestones live in DOMAIN_TRACKER. + await client.update(DOMAIN_TASK, { _class: tracker.class.Issue, startDate: { $exists: false } }, { startDate: null }) + await client.update( + DOMAIN_TRACKER, + { _class: tracker.class.Milestone, startDate: { $exists: false } }, + { startDate: null } + ) +} + async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLogger): Promise { const defaultTypeId = tracker.ids.ClassingProjectType const typeDescriptor = tracker.descriptors.ProjectType @@ -398,6 +409,11 @@ export const trackerOperation: MigrateOperation = { state: 'migrateDefaultTypeMixins', mode: 'upgrade', func: migrateDefaultTypeMixins + }, + { + state: 'gantt-add-startdate', + mode: 'upgrade', + func: migrateAddStartDate } ]) }, diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 09d3421f30..249a592ef6 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -58,10 +58,12 @@ import time, { type ToDo } from '@hcengineering/time' import { type ProjectTargetPreference, type Component, + type DependencyKind, type Issue, type IssueChildInfo, type IssueParentInfo, type IssuePriority, + type IssueRelation, type IssueStatus, type IssueTemplate, type IssueTemplateChild, @@ -233,6 +235,10 @@ export class TIssue extends TTask implements Issue { @ReadOnly() declare space: Ref + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.IssueStartDate) + @Index(IndexKind.Indexed) + declare startDate: Timestamp | null + @Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.DueDate) declare dueDate: Timestamp | null @@ -340,6 +346,28 @@ export class TTimeSpendReport extends TAttachedDoc implements TimeSpendReport { @Prop(TypeString(), tracker.string.TimeSpendReportDescription) description!: string } + +/** + * @public + */ +@Model(tracker.class.IssueRelation, core.class.AttachedDoc, DOMAIN_TRACKER) +@UX(tracker.string.GanttDependency, tracker.icon.Issue) +export class TIssueRelation extends TAttachedDoc implements IssueRelation { + @Prop(TypeRef(tracker.class.Issue), tracker.string.Issue) + declare attachedTo: Ref + + declare collection: 'relations' + + @Prop(TypeRef(tracker.class.Issue), tracker.string.Issue) + @Index(IndexKind.Indexed) + target!: Ref + + @Prop(TypeString(), tracker.string.GanttDependency) + kind!: DependencyKind + + @Prop(TypeNumber(), tracker.string.GanttLag) + lag!: number +} /** * @public */ @@ -389,6 +417,9 @@ export class TMilestone extends TDoc implements Milestone { @Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files }) attachments?: number + @Prop(TypeDate(), tracker.string.StartDate) + startDate!: Timestamp | null + @Prop(TypeDate(), tracker.string.TargetDate) targetDate!: Timestamp diff --git a/models/tracker/src/viewlets.ts b/models/tracker/src/viewlets.ts index 8cb2463fc2..509e00d7d0 100644 --- a/models/tracker/src/viewlets.ts +++ b/models/tracker/src/viewlets.ts @@ -663,7 +663,7 @@ export function defineViewlets (builder: Builder): void { viewOptions: milestoneOptions, configOptions: { strict: true, - hiddenKeys: ['targetDate', 'label', 'description'] + hiddenKeys: ['startDate', 'targetDate', 'label', 'description'] }, config: [ { @@ -672,6 +672,12 @@ export function defineViewlets (builder: Builder): void { }, { key: '', presenter: tracker.component.MilestonePresenter, props: { shouldUseMargin: true } }, { key: '', displayProps: { grow: true } }, + { + key: '', + label: tracker.string.StartDate, + presenter: tracker.component.MilestoneDatePresenter, + props: { field: 'startDate' } + }, { key: '', label: tracker.string.TargetDate, diff --git a/packages/importer/src/importer/importer.ts b/packages/importer/src/importer/importer.ts index 73bdd7bb3b..5c5d30b7b5 100644 --- a/packages/importer/src/importer/importer.ts +++ b/packages/importer/src/importer/importer.ts @@ -591,6 +591,7 @@ export class WorkspaceImporter { rank, comments: issue.comments?.length ?? 0, subIssues: issue.subdocs.length, + startDate: null, dueDate: null, parents: parentsInfo, remainingTime, diff --git a/packages/ui/src/components/notifications/Notifications.svelte b/packages/ui/src/components/notifications/Notifications.svelte index 2252d7dcbd..57d4025d74 100644 --- a/packages/ui/src/components/notifications/Notifications.svelte +++ b/packages/ui/src/components/notifications/Notifications.svelte @@ -14,7 +14,7 @@ -
+
{#each Object.entries(positionByClassName) as [className, position]}
{#each $store.slice(0, maxVisibleNotifications) as notification (notification.id)} diff --git a/plugins/calendar/src/index.ts b/plugins/calendar/src/index.ts index 6d9b32dfa6..f85c49abd3 100644 --- a/plugins/calendar/src/index.ts +++ b/plugins/calendar/src/index.ts @@ -257,6 +257,7 @@ const calendarPlugin = plugin(calendarId, { PersonsLabel: '' as IntlString, EventNumber: '' as IntlString, Reminders: '' as IntlString, + Reminder: '' as IntlString, Today: '' as IntlString, Visibility: '' as IntlString, Public: '' as IntlString, diff --git a/plugins/card-resources/src/components/ChangeType.svelte b/plugins/card-resources/src/components/ChangeType.svelte index 9ba96daef1..116bc8b5fc 100644 --- a/plugins/card-resources/src/components/ChangeType.svelte +++ b/plugins/card-resources/src/components/ChangeType.svelte @@ -28,12 +28,12 @@ const client = getClient() const hierarchy = client.getHierarchy() - let selected: Ref = value._class + let selected: Ref | null = value._class $: mapping = buildMapping(selected, value._class) async function changeType (): Promise { - if (selected === undefined || selected === value._class) return + if (selected == null || selected === value._class) return const cloned = hierarchy.clone(value) applyMapping(cloned, mapping) const update = fillDefaults(hierarchy, cloned, selected) @@ -54,8 +54,8 @@ } } - function buildMapping (selected: Ref | undefined, current: Ref): Record { - if (selected === undefined || selected === current) return {} + function buildMapping (selected: Ref | null, current: Ref): Record { + if (selected == null || selected === current) return {} const selectedAttributes = hierarchy.getAllAttributes(selected, card.class.Card) const currentAttributes = hierarchy.getAllAttributes(current, card.class.Card) const res: Record = {} @@ -81,7 +81,7 @@ label={card.string.ChangeType} okLabel={ui.string.Ok} okAction={changeType} - canSave={selected !== undefined && selected !== value._class} + canSave={selected != null && selected !== value._class} gap={'gapV-4'} on:close={() => { dispatch('close') diff --git a/plugins/card-resources/src/components/CreateCardPopupFull.svelte b/plugins/card-resources/src/components/CreateCardPopupFull.svelte index d770ccd63c..a70fd9ed26 100644 --- a/plugins/card-resources/src/components/CreateCardPopupFull.svelte +++ b/plugins/card-resources/src/components/CreateCardPopupFull.svelte @@ -12,8 +12,13 @@ - + {#if type != null} + + {/if}
{#if changeType}
- +
{/if} {#if (space == null || allowChangeSpace) && !(extension?.hideSpace ?? false)} diff --git a/plugins/card-resources/src/components/CreateCardPopupSimple.svelte b/plugins/card-resources/src/components/CreateCardPopupSimple.svelte index b5308d8a41..f5f4247340 100644 --- a/plugins/card-resources/src/components/CreateCardPopupSimple.svelte +++ b/plugins/card-resources/src/components/CreateCardPopupSimple.svelte @@ -12,8 +12,8 @@ - + diff --git a/plugins/card-resources/src/components/TypeSelector.svelte b/plugins/card-resources/src/components/TypeSelector.svelte index 44dd9d9e23..586efe801b 100644 --- a/plugins/card-resources/src/components/TypeSelector.svelte +++ b/plugins/card-resources/src/components/TypeSelector.svelte @@ -20,41 +20,53 @@ import { createEventDispatcher } from 'svelte' import card from '../plugin' import view from '@hcengineering/view' - import { getFirstCreatableSubtype, isBaseTypeWithSubtypes } from '../utils' + import { getFirstCreatableSubtype, getRootType, isBaseTypeWithSubtypes } from '../utils' - export let value: Ref + export let value: Ref | null export let width: string | undefined = undefined export let kind: ButtonKind | undefined = undefined export let size: ButtonSize | undefined = undefined export let parent: Ref = card.class.Card export let disabled: boolean = false export let excludeBaseTypes: boolean = false + export let allowedRootTypes: Ref[] | undefined = undefined const client = getClient() const hierarchy = client.getHierarchy() const dispatch = createEventDispatcher() - function filterClasses (): [DropdownIntlItem, DropdownIntlItem[]][] { - const descendants = hierarchy.getDescendants(parent).filter((p) => p !== parent) + function isAllowedBySpace (type: Ref, roots: Ref[] | undefined): boolean { + return roots === undefined || roots.includes(getRootType(hierarchy, type)) + } + + function isSelectableClass (_class: Class, roots: Ref[] | undefined, skipBaseTypes: boolean): boolean { + if (_class.label === undefined) return false + if (_class.kind !== ClassifierKind.CLASS) return false + if ((_class as MasterTag).removed === true) return false + if (!isAllowedBySpace(_class._id as Ref, roots)) return false + if (skipBaseTypes && isBaseTypeWithSubtypes(hierarchy, _class._id as Ref)) return false + return true + } + + function filterClasses ( + root: Ref, + roots: Ref[] | undefined, + skipBaseTypes: boolean + ): [DropdownIntlItem, DropdownIntlItem[]][] { + const descendants = hierarchy.getDescendants(root).filter((p) => p !== root) const added = new Set>>() const base = new Map>, Class[]>() for (const _id of descendants) { if (added.has(_id)) continue const _class = hierarchy.getClass(_id) - if (_class.label === undefined) continue - if (_class.kind !== ClassifierKind.CLASS) continue - if ((_class as MasterTag).removed === true) continue - if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, _id as Ref)) continue + if (!isSelectableClass(_class, roots, skipBaseTypes)) continue added.add(_id) const descendants = hierarchy.getDescendants(_id) const toAdd: Class[] = [] for (const desc of descendants) { if (added.has(desc)) continue const _class = hierarchy.getClass(desc) - if (_class.label === undefined) continue - if (_class.kind !== ClassifierKind.CLASS) continue - if ((_class as MasterTag).removed === true) continue - if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, desc as Ref)) continue + if (!isSelectableClass(_class, roots, skipBaseTypes)) continue added.add(desc) toAdd.push(_class) } @@ -82,9 +94,10 @@ } } - const classes = filterClasses() + let classes: [DropdownIntlItem, DropdownIntlItem[]][] = [] + $: classes = filterClasses(parent, allowedRootTypes, excludeBaseTypes) - $: if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, value)) { + $: if (value != null && excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, value)) { const nextType = getFirstCreatableSubtype(hierarchy, value) if (nextType !== undefined) { value = nextType @@ -92,12 +105,20 @@ } } - $: selectedClass = hierarchy.getClass(value) - $: selected = { - id: selectedClass._id, - label: selectedClass.label, - ...getIconProps(selectedClass) + $: if (value != null && !isAllowedBySpace(value, allowedRootTypes)) { + value = null + dispatch('change', value) } + + $: selectedClass = value != null ? hierarchy.getClass(value) : undefined + $: selected = + selectedClass !== undefined + ? { + id: selectedClass._id, + label: selectedClass.label, + ...getIconProps(selectedClass) + } + : undefined
diff --git a/plugins/export-resources/src/export.ts b/plugins/export-resources/src/export.ts index 69333a989b..12c227e431 100644 --- a/plugins/export-resources/src/export.ts +++ b/plugins/export-resources/src/export.ts @@ -26,7 +26,8 @@ export async function exportToWorkspace ( targetWorkspace: string | undefined, relations?: RelationDefinition[] | undefined, skipDeletedObsolete?: boolean, - exportOnlyEffective?: boolean + exportOnlyEffective?: boolean, + includeChildren?: boolean ): Promise { const lang = getCurrentLanguage() @@ -73,7 +74,8 @@ export async function exportToWorkspace ( _class, fieldMappers, skipDeletedObsolete, - exportOnlyEffective + exportOnlyEffective, + includeChildren } if (relations != null) { body.relations = relations diff --git a/plugins/export-resources/src/plugin.ts b/plugins/export-resources/src/plugin.ts index 34f8804ce0..6d77384dfc 100644 --- a/plugins/export-resources/src/plugin.ts +++ b/plugins/export-resources/src/plugin.ts @@ -56,6 +56,8 @@ export default mergeIds(exportId, exportPlugin, { ExportFilterMode: '' as IntlString, ExportFilterEffectiveOnly: '' as IntlString, ExportFilterSkipArchivedObsolete: '' as IntlString, - ExportFilterAll: '' as IntlString + ExportFilterAll: '' as IntlString, + ExportChildDocuments: '' as IntlString, + ExportChildDocumentsDescription: '' as IntlString } }) diff --git a/plugins/process-resources/src/components/settings/ResultEditor.svelte b/plugins/process-resources/src/components/settings/ResultEditor.svelte index ef6e4666d8..35ea436716 100644 --- a/plugins/process-resources/src/components/settings/ResultEditor.svelte +++ b/plugins/process-resources/src/components/settings/ResultEditor.svelte @@ -44,7 +44,7 @@ type } if (key !== undefined) { - const attr = client.getHierarchy().findAttribute(process.masterTag, key) + const attr = client.getModel().findAllSync(core.class.Attribute, { name: key })[0] if (attr?.label !== undefined) { name = await translate(attr.label, {}) result.name = name diff --git a/plugins/process-resources/src/middleware.ts b/plugins/process-resources/src/middleware.ts index 6fcf26ee64..dcc1497bd3 100644 --- a/plugins/process-resources/src/middleware.ts +++ b/plugins/process-resources/src/middleware.ts @@ -243,10 +243,9 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre results = await Promise.all( results.map(async (r) => { if (r.key !== undefined) { - const h = this.client.getHierarchy() const _process = this.client.getModel().findObject(execution.process) if (_process !== undefined) { - const attr = h.findAttribute(_process.masterTag, r.key) + const attr = this.client.getModel().findAllSync(core.class.Attribute, { name: r.key })[0] if (attr?.label !== undefined) { const name = await translate(attr.label, {}) return { ...r, name } diff --git a/plugins/time-resources/src/components/CreateToDoPopup.svelte b/plugins/time-resources/src/components/CreateToDoPopup.svelte index 036eb52e37..76f664893b 100644 --- a/plugins/time-resources/src/components/CreateToDoPopup.svelte +++ b/plugins/time-resources/src/components/CreateToDoPopup.svelte @@ -15,7 +15,7 @@ - +
+ + {#if slots.length > 0} +
+ +
+ {/if} +
diff --git a/plugins/tracker-assets/lang/cs.json b/plugins/tracker-assets/lang/cs.json index f1e9d23d05..dea411668a 100644 --- a/plugins/tracker-assets/lang/cs.json +++ b/plugins/tracker-assets/lang/cs.json @@ -115,6 +115,9 @@ "NoAssignee": "Bez přiřazení", "LastUpdated": "Poslední aktualizace", "DueDate": "Datum splnění", + "IssueStartDate": "Datum zahájení", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manuální", "All": "Vše", "PastWeek": "Minulý týden", diff --git a/plugins/tracker-assets/lang/de.json b/plugins/tracker-assets/lang/de.json index 99fe946c9d..d1e698c9c9 100644 --- a/plugins/tracker-assets/lang/de.json +++ b/plugins/tracker-assets/lang/de.json @@ -125,6 +125,9 @@ "NoAssignee": "Nicht zugewiesen", "LastUpdated": "Zuletzt aktualisiert", "DueDate": "Fälligkeitsdatum", + "IssueStartDate": "Startdatum", + "GanttDependency": "Abhängigkeit", + "GanttLag": "Verzögerung", "Manual": "Manuell", "All": "Alle", "PastWeek": "Letzte Woche", diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 4345dc5a0a..3d856a2003 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -125,6 +125,9 @@ "NoAssignee": "No assignee", "LastUpdated": "Last updated", "DueDate": "Due date", + "IssueStartDate": "Start date", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manual", "All": "All", "PastWeek": "Past week", diff --git a/plugins/tracker-assets/lang/es.json b/plugins/tracker-assets/lang/es.json index 2699c92764..ed9795ef41 100644 --- a/plugins/tracker-assets/lang/es.json +++ b/plugins/tracker-assets/lang/es.json @@ -123,6 +123,9 @@ "NoAssignee": "Sin asignar", "LastUpdated": "Última actualización", "DueDate": "Fecha de vencimiento", + "IssueStartDate": "Fecha de inicio", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manual", "All": "Todos", "PastWeek": "Semana pasada", diff --git a/plugins/tracker-assets/lang/fr.json b/plugins/tracker-assets/lang/fr.json index 7b1f0c9466..daa7f4eaab 100644 --- a/plugins/tracker-assets/lang/fr.json +++ b/plugins/tracker-assets/lang/fr.json @@ -123,6 +123,9 @@ "NoAssignee": "Non assigné", "LastUpdated": "Dernière mise à jour", "DueDate": "Date d'échéance", + "IssueStartDate": "Date de début", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manuel", "All": "Tous", "PastWeek": "La semaine passée", diff --git a/plugins/tracker-assets/lang/it.json b/plugins/tracker-assets/lang/it.json index 7113d55111..7fe2285f26 100644 --- a/plugins/tracker-assets/lang/it.json +++ b/plugins/tracker-assets/lang/it.json @@ -123,6 +123,9 @@ "NoAssignee": "Nessun assegnatario", "LastUpdated": "Ultimo aggiornamento", "DueDate": "Data di scadenza", + "IssueStartDate": "Data di inizio", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manuale", "All": "Tutti", "PastWeek": "Settimana scorsa", diff --git a/plugins/tracker-assets/lang/ja.json b/plugins/tracker-assets/lang/ja.json index 58f184c749..34426b0987 100644 --- a/plugins/tracker-assets/lang/ja.json +++ b/plugins/tracker-assets/lang/ja.json @@ -123,6 +123,9 @@ "NoAssignee": "担当者なし", "LastUpdated": "最終更新日", "DueDate": "期日", + "IssueStartDate": "開始日", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "手動", "All": "すべて", "PastWeek": "先週", diff --git a/plugins/tracker-assets/lang/ko.json b/plugins/tracker-assets/lang/ko.json index b89719af61..32b4b4c75c 100644 --- a/plugins/tracker-assets/lang/ko.json +++ b/plugins/tracker-assets/lang/ko.json @@ -123,6 +123,9 @@ "NoAssignee": "담당자 없음", "LastUpdated": "최근 업데이트", "DueDate": "마감일", + "IssueStartDate": "시작일", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "수동", "All": "전체", "PastWeek": "지난주", diff --git a/plugins/tracker-assets/lang/pt-br.json b/plugins/tracker-assets/lang/pt-br.json index 828c9ca50b..9d8ec0214e 100644 --- a/plugins/tracker-assets/lang/pt-br.json +++ b/plugins/tracker-assets/lang/pt-br.json @@ -123,6 +123,9 @@ "NoAssignee": "Sem atribuição", "LastUpdated": "Última atualização", "DueDate": "Data de vencimento", + "IssueStartDate": "Data de início", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manual", "All": "Todos", "PastWeek": "Semana passada", diff --git a/plugins/tracker-assets/lang/pt.json b/plugins/tracker-assets/lang/pt.json index da5a3e85e1..82a640e2c5 100644 --- a/plugins/tracker-assets/lang/pt.json +++ b/plugins/tracker-assets/lang/pt.json @@ -123,6 +123,9 @@ "NoAssignee": "Sem atribuição", "LastUpdated": "Última atualização", "DueDate": "Data de vencimento", + "IssueStartDate": "Data de início", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manual", "All": "Todos", "PastWeek": "Semana passada", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 642877b385..7ccd4cb5cc 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -125,6 +125,9 @@ "NoAssignee": "Нет исполнителя", "LastUpdated": "Последнее обновление", "DueDate": "Срок", + "IssueStartDate": "Дата начала", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Пользовательский", "All": "Все", "PastWeek": "Предыдущая неделя", diff --git a/plugins/tracker-assets/lang/tr.json b/plugins/tracker-assets/lang/tr.json index 9f66c50ea5..ec11801435 100644 --- a/plugins/tracker-assets/lang/tr.json +++ b/plugins/tracker-assets/lang/tr.json @@ -123,6 +123,9 @@ "NoAssignee": "Atanan yok", "LastUpdated": "Son güncelleme", "DueDate": "Bitiş tarihi", + "IssueStartDate": "Başlangıç tarihi", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "Manuel", "All": "Tümü", "PastWeek": "Geçen hafta", diff --git a/plugins/tracker-assets/lang/zh.json b/plugins/tracker-assets/lang/zh.json index 8e05a889e1..a424d856ca 100644 --- a/plugins/tracker-assets/lang/zh.json +++ b/plugins/tracker-assets/lang/zh.json @@ -125,6 +125,9 @@ "NoAssignee": "无受理人", "LastUpdated": "最后更新", "DueDate": "截止日期", + "IssueStartDate": "开始日期", + "GanttDependency": "Dependency", + "GanttLag": "Lag", "Manual": "手动", "All": "全部", "PastWeek": "过去一周", diff --git a/plugins/tracker-resources/src/components/CreateIssue.svelte b/plugins/tracker-resources/src/components/CreateIssue.svelte index 7878ea9d75..224ce28c9c 100644 --- a/plugins/tracker-resources/src/components/CreateIssue.svelte +++ b/plugins/tracker-resources/src/components/CreateIssue.svelte @@ -186,6 +186,7 @@ priority: priority ?? IssuePriority.NoPriority, space: _space as Ref, component: component ?? $activeComponent ?? null, + startDate: null, dueDate: null, attachments: 0, estimation: 0, @@ -312,6 +313,7 @@ _id: generateId(), space: _space as Ref, subIssues: [], + startDate: null, dueDate: null, labels: p.labels !== undefined @@ -488,6 +490,7 @@ rank: '', comments: 0, subIssues: 0, + startDate: object.startDate, dueDate: object.dueDate, parents: parentIssue != null diff --git a/plugins/tracker-resources/src/components/SubIssues.svelte b/plugins/tracker-resources/src/components/SubIssues.svelte index d0e4bf0835..86b8b2264e 100644 --- a/plugins/tracker-resources/src/components/SubIssues.svelte +++ b/plugins/tracker-resources/src/components/SubIssues.svelte @@ -86,6 +86,7 @@ rank: '', comments: 0, subIssues: 0, + startDate: subIssue.startDate ?? null, dueDate: null, parents, reportedTime: 0, diff --git a/plugins/tracker-resources/src/components/issues/StartDateEditor.svelte b/plugins/tracker-resources/src/components/issues/StartDateEditor.svelte new file mode 100644 index 0000000000..b042f9d15d --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/StartDateEditor.svelte @@ -0,0 +1,53 @@ + + + +{#if value} + handleStartDateChanged(e)} + shouldIgnoreOverdue={true} + /> +{/if} diff --git a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte index 33f131bc57..9aa91228e2 100644 --- a/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte +++ b/plugins/tracker-resources/src/components/issues/edit/ControlPanel.svelte @@ -35,6 +35,7 @@ import DueDateEditor from '../DueDateEditor.svelte' import PriorityEditor from '../PriorityEditor.svelte' import RelationEditor from '../RelationEditor.svelte' + import StartDateEditor from '../StartDateEditor.svelte' import StatusEditor from '../StatusEditor.svelte' import notification from '@hcengineering/notification' @@ -60,6 +61,7 @@ 'number', 'assignee', 'component', + 'startDate', 'dueDate', 'milestone', 'relations', @@ -202,14 +204,17 @@ - {#if issue.dueDate !== null} -
+
- - - - {/if} + + + + + + + {#if keys.length > 0}
diff --git a/plugins/tracker-resources/src/components/milestones/EditMilestone.svelte b/plugins/tracker-resources/src/components/milestones/EditMilestone.svelte index 1f8d90e19b..fc9ef23e65 100644 --- a/plugins/tracker-resources/src/components/milestones/EditMilestone.svelte +++ b/plugins/tracker-resources/src/components/milestones/EditMilestone.svelte @@ -16,9 +16,10 @@ import { AttachmentStyleBoxEditor } from '@hcengineering/attachment-resources' import { getClient } from '@hcengineering/presentation' import { Milestone } from '@hcengineering/tracker' - import { EditBox, Label } from '@hcengineering/ui' + import { DatePresenter, EditBox, Label } from '@hcengineering/ui' import { createEventDispatcher, onMount } from 'svelte' import tracker from '../../plugin' + import MilestoneStatusEditor from './MilestoneStatusEditor.svelte' import QueryIssuesList from '../issues/edit/QueryIssuesList.svelte' export let object: Milestone @@ -33,12 +34,27 @@ await client.update(object, { [field]: value }) } + async function changeStartDate (value: number | null | undefined): Promise { + await client.update(object, { startDate: value ?? null }) + } + async function changeTargetDate (value: number | null | undefined): Promise { + if (value === null || value === undefined) return + await client.update(object, { targetDate: value }) + } + $: if (oldLabel !== object.label) { oldLabel = object.label rawLabel = object.label } - onMount(() => dispatch('open', { ignoreKeys: ['label', 'description', 'attachments'] })) + // status / startDate / targetDate are rendered in this component's body in + // chronological order (Status → Start → Target). Hide them from the + // auto-generated side panel so they don't appear twice. + onMount(() => + dispatch('open', { + ignoreKeys: ['label', 'description', 'attachments', 'status', 'startDate', 'targetDate'] + }) + ) $: descriptionKey = client.getHierarchy().getAttribute(tracker.class.Component, 'description') let descriptionBox: AttachmentStyleBoxEditor @@ -58,6 +74,37 @@ }} /> +
+
+ + +
+
+ + { + void changeStartDate(e.detail) + }} + /> +
+
+ + { + void changeTargetDate(e.detail) + }} + /> +
+
+
+ + diff --git a/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte b/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte index 6139262bc3..a74c93fa50 100644 --- a/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte +++ b/plugins/tracker-resources/src/components/milestones/NewMilestone.svelte @@ -34,6 +34,7 @@ status: MilestoneStatus.Planned, comments: 0, attachments: 0, + startDate: null, targetDate: Date.now() + 14 * 24 * 60 * 60 * 1000 } @@ -76,6 +77,14 @@ /> + milestone?: Ref | null @@ -236,6 +251,7 @@ export interface IssueDraft { assignee: Ref | null component: Ref | null space: Ref + startDate: Timestamp | null dueDate: Timestamp | null milestone?: Ref | null @@ -323,6 +339,21 @@ export interface IssueParentInfo { space: Ref } +/** + * Typed dependency between two Issues, used by the Gantt view to compute + * cascade scheduling and critical path. + * + * Persisted as an AttachedDoc collection 'relations' on the source Issue. + * + * @public + */ +export interface IssueRelation extends AttachedDoc { + target: Ref // successor + kind: DependencyKind + /** Lag in schedule days; can be negative (overlap). */ + lag: number +} + /** * @public */ @@ -366,6 +397,7 @@ const pluginState = plugin(trackerId, { class: { Project: '' as Ref>, Issue: '' as Ref>, + IssueRelation: '' as Ref>, IssueTemplate: '' as Ref>, Component: '' as Ref>, IssueStatus: '' as Ref>, @@ -520,6 +552,9 @@ const pluginState = plugin(trackerId, { Project: '' as IntlString, RelatedIssues: '' as IntlString, Issue: '' as IntlString, + IssueStartDate: '' as IntlString, + GanttDependency: '' as IntlString, + GanttLag: '' as IntlString, NewProject: '' as IntlString, UnsetParentIssue: '' as IntlString, ForbidCreateProjectPermission: '' as IntlString, diff --git a/plugins/view-resources/src/components/DocTable.svelte b/plugins/view-resources/src/components/DocTable.svelte index e2de1c218a..653b03e78d 100644 --- a/plugins/view-resources/src/components/DocTable.svelte +++ b/plugins/view-resources/src/components/DocTable.svelte @@ -47,7 +47,20 @@ const refs: HTMLElement[] = [] - $: refs.length = objects.length + $: uniqueObjects = deduplicate(objects) + + function deduplicate (list: Doc[] | undefined): Doc[] { + if (!list) return [] + const seen = new Set() + return list.filter((item) => { + if (item?._id == null) return false + if (seen.has(item._id)) return false + seen.add(item._id) + return true + }) + } + + $: refs.length = uniqueObjects.length $: viewlet = getViewlet(_class) @@ -210,9 +223,9 @@ {/if} - {#if objects.length > 0} + {#if uniqueObjects.length > 0} - {#each objects as object, row (object._id)} + {#each uniqueObjects as object, row (object._id)} () + return list.filter((item) => { + if (item?._id == null) return false + if (seen.has(item._id)) return false + seen.add(item._id) + return true + }) + } + function getCreate (): ObjectCreate | undefined { const factory = client.getHierarchy().classHierarchyMixin(_class, view.mixin.ObjectFactory) if (factory) { @@ -53,7 +66,7 @@ function add (): void { const create = getCreate() const isVersionable = client.getHierarchy().classHierarchyMixin(_class, core.mixin.VersionableClass) !== undefined - const baseQuery = { _id: { $nin: docs.map((p) => p._id) } } + const baseQuery = { _id: { $nin: uniqueDocs.map((p) => p._id) } } const docQuery = isVersionable ? { isLatest: true, ...baseQuery } : baseQuery showPopup( ObjectBoxPopup, @@ -120,7 +133,7 @@ return direction === 'B' } - $: allowToCreate = isAllowedToCreate(association, docs, direction) + $: allowToCreate = isAllowedToCreate(association, uniqueDocs, direction) $: classLabel = client.getHierarchy().getClass(_class).label @@ -139,9 +152,9 @@ - {#if docs?.length > 0 && config != null} + {#if uniqueDocs?.length > 0 && config != null} - + {:else if !readonly}
>, key: string): boolean { + return hierarchy.getAllAttributes(sourceClass).has(getAttributeKey(key)) + } + + function syncConfigOrder ( + sourceClass: Ref>, + previousSourceConfig: Array, + sourceConfig: Array, + targetConfig: Array + ): Array { + const sourceKeys = new Set(sourceConfig.map(getKey).filter((it): it is string => it !== undefined)) + const previousSourceKeys = new Set(previousSourceConfig.map(getKey).filter((it): it is string => it !== undefined)) + const targetByKey = new Map>() + for (const [index, item] of targetConfig.entries()) { + const key = getKey(item) + if (key === undefined) continue + const items = targetByKey.get(key) ?? [] + items.push({ item, index }) + targetByKey.set(key, items) + } + + const sourceItems: Array = [] + const usedIndexes = new Set() + for (const sourceItem of sourceConfig) { + const key = getKey(sourceItem) + if (key === undefined) continue + + const targetItem = targetByKey.get(key)?.shift() + sourceItems.push(targetItem?.item ?? sourceItem) + if (targetItem !== undefined) { + usedIndexes.add(targetItem.index) + } + } + + const synced = [...sourceItems] + for (const [index, targetItem] of targetConfig.entries()) { + if (usedIndexes.has(index)) continue + + const key = getKey(targetItem) + if ( + key !== undefined && + !sourceKeys.has(key) && + (previousSourceKeys.has(key) || isSourceAttribute(sourceClass, key)) + ) { + continue + } + + synced.splice(Math.min(index, synced.length), 0, targetItem) + } + + return synced + } + function isExist (result: Config[], newValue: Config): boolean { if (!isAttribute(newValue)) return false const newValueKey = getKey(newValue.value) @@ -391,6 +452,45 @@ return preference === undefined ? result : setStatus(result, preference) } + async function upsertViewletPreference ( + viewletId: Ref, + config: Array + ): Promise { + const preference = preferences.find((p) => p.attachedTo === viewletId) + if (preference !== undefined) { + if (!deepEqual(preference.config, config)) { + await client.update(preference, { + config + }) + } + } else { + await client.createDoc(view.class.ViewletPreference, core.space.Workspace, { + attachedTo: viewletId, + config + }) + } + } + + async function syncChildViewletPreferences ( + sourceViewlet: Viewlet, + previousSourceConfig: Array, + sourceConfig: Array + ): Promise { + const descendants = new Set( + hierarchy.getDescendants(sourceViewlet.attachTo).filter((it) => it !== sourceViewlet.attachTo) + ) + for (const childViewlet of viewlets) { + if (!descendants.has(childViewlet.attachTo)) continue + + const preference = preferences.find((p) => p.attachedTo === childViewlet._id) + const targetConfig = preference?.config ?? childViewlet.config + const config = syncConfigOrder(sourceViewlet.attachTo, previousSourceConfig, sourceConfig, targetConfig) + if (deepEqual(targetConfig, config)) continue + + await upsertViewletPreference(childViewlet._id, config) + } + } + async function addAssociations ( result: Config[], _class: Ref>, @@ -437,16 +537,14 @@ } return value }) - const preference = preferences.find((p) => p.attachedTo === viewletId) - if (preference !== undefined) { - await client.update(preference, { - config - }) - } else { - await client.createDoc(view.class.ViewletPreference, core.space.Workspace, { - attachedTo: viewletId, - config - }) + const selectedViewlet = viewlets.find((it) => it._id === viewletId) + const previousSourceConfig = + preferences.find((p) => p.attachedTo === viewletId)?.config ?? selectedViewlet?.config ?? [] + + await upsertViewletPreference(viewletId, config) + + if (selectedViewlet !== undefined) { + await syncChildViewletPreferences(selectedViewlet, previousSourceConfig, config) } } diff --git a/plugins/view-resources/src/components/filter/FilterBar.svelte b/plugins/view-resources/src/components/filter/FilterBar.svelte index fd1820fd96..427ff36259 100644 --- a/plugins/view-resources/src/components/filter/FilterBar.svelte +++ b/plugins/view-resources/src/components/filter/FilterBar.svelte @@ -13,7 +13,7 @@ // limitations under the License. -->