From 41ff1b7c9326ea5b9e0cade1f7e51ece249bca6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Mon, 20 Apr 2026 09:45:37 +0200 Subject: [PATCH 01/17] [frontend] Sort custom views by name --- .../src/private/components/custom_views/useCustomViews.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts b/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts index 1bf9f89d36f8..3c024ebbe71b 100644 --- a/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts +++ b/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts @@ -27,8 +27,11 @@ export const useCustomViews = (entityType: string) => { } const customViews = customViewsContextForType.custom_views_info ?? []; const getCurrentCustomViewTab = matchPath(customViews); + const sortedCustomViews = [...customViews].sort( + (lhs, rhs) => lhs.name.localeCompare(rhs.name), + ); return { - customViews, + customViews: sortedCustomViews, getCurrentCustomViewTab, }; }; From 2d1a86d5f9a4d6ae5d6a20bc8ecb45b8b5354f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Sun, 19 Apr 2026 02:20:35 +0200 Subject: [PATCH 02/17] [backend] Add a custom view --- .../src/schema/relay.schema.graphql | 9 +++++++ opencti-platform/opencti-graphql/package.json | 1 + .../opencti-graphql/src/generated/graphql.ts | 16 ++++++++++++ .../modules/customView/customView-domain.ts | 26 ++++++++++++++++++- .../modules/customView/customView-resolver.ts | 8 ++++-- .../src/modules/customView/customView.graphql | 16 ++++++++++++ opencti-platform/opencti-graphql/yarn.lock | 8 ++++++ 7 files changed, 81 insertions(+), 3 deletions(-) diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index a766c7c5f0de..3330be8f7eb2 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -10759,6 +10759,7 @@ type Mutation { ldapProviderAdd(input: LdapInput!): AuthenticationProvider ldapProviderEdit(id: ID!, input: LdapInput!): AuthenticationProvider ldapProviderDelete(id: ID!): ID + customViewAdd(input: CustomViewAddInput!): CustomView! taxiiCollectionAdd(input: TaxiiCollectionAddInput!): TaxiiCollection taxiiCollectionEdit(id: ID!): TaxiiCollectionEditMutations feedAdd(input: FeedAddInput!): Feed @@ -16203,6 +16204,14 @@ type CustomViewsSettings { customViews: [CustomView!]! } +input CustomViewAddInput { + "*Constraints:*\n* Minimal length: `2`\n* Must match format: `not-blank`\n" + name: String! + description: String + manifest: String + target_entity_type: String! +} + type TaxiiCollection { id: ID! name: String diff --git a/opencti-platform/opencti-graphql/package.json b/opencti-platform/opencti-graphql/package.json index 8f3d059c5a61..9a3ed0a6300d 100644 --- a/opencti-platform/opencti-graphql/package.json +++ b/opencti-platform/opencti-graphql/package.json @@ -200,6 +200,7 @@ "@types/node-forge": "1.3.14", "@types/ramda": "0.31.1", "@types/semver": "7.7.1", + "@types/slug": "5.0.9", "@types/turndown": "5.0.6", "@types/validator": "13.15.10", "@types/xml2js": "0.4.14", diff --git a/opencti-platform/opencti-graphql/src/generated/graphql.ts b/opencti-platform/opencti-graphql/src/generated/graphql.ts index f099b9db1c43..ba839d64170d 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -6449,6 +6449,13 @@ export type CustomView = BasicObject & InternalObject & { updated_at: Scalars['DateTime']['output']; }; +export type CustomViewAddInput = { + description?: InputMaybe; + manifest?: InputMaybe; + name: Scalars['String']['input']; + target_entity_type: Scalars['String']['input']; +}; + export type CustomViewsDisplayContext = { __typename?: 'CustomViewsDisplayContext'; custom_views_info: Array; @@ -16911,6 +16918,7 @@ export type Mutation = { csvMapperDelete?: Maybe; csvMapperFieldPatch?: Maybe; csvMapperTest?: Maybe; + customViewAdd: CustomView; dataComponentAdd?: Maybe; dataComponentContextClean?: Maybe; dataComponentContextPatch?: Maybe; @@ -17725,6 +17733,11 @@ export type MutationCsvMapperTestArgs = { }; +export type MutationCustomViewAddArgs = { + input: CustomViewAddInput; +}; + + export type MutationDataComponentAddArgs = { input: DataComponentAddInput; }; @@ -38972,6 +38985,7 @@ export type ResolversTypes = ResolversObject<{ CsvMapperTestResult: ResolverTypeWrapper; CurrentConnectorStatusInput: CurrentConnectorStatusInput; CustomView: ResolverTypeWrapper; + CustomViewAddInput: CustomViewAddInput; CustomViewsDisplayContext: ResolverTypeWrapper & { custom_views_info: Array }>; CustomViewsSettings: ResolverTypeWrapper & { customViews: Array }>; DataComponent: ResolverTypeWrapper; @@ -40050,6 +40064,7 @@ export type ResolversParentTypes = ResolversObject<{ CsvMapperTestResult: CsvMapperTestResult; CurrentConnectorStatusInput: CurrentConnectorStatusInput; CustomView: BasicStoreEntityCustomView; + CustomViewAddInput: CustomViewAddInput; CustomViewsDisplayContext: Omit & { custom_views_info: Array }; CustomViewsSettings: Omit & { customViews: Array }; DataComponent: BasicStoreEntityDataComponent; @@ -46583,6 +46598,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; csvMapperFieldPatch?: Resolver, ParentType, ContextType, RequireFields>; csvMapperTest?: Resolver, ParentType, ContextType, RequireFields>; + customViewAdd?: Resolver>; dataComponentAdd?: Resolver, ParentType, ContextType, RequireFields>; dataComponentContextClean?: Resolver, ParentType, ContextType, RequireFields>; dataComponentContextPatch?: Resolver, ParentType, ContextType, RequireFields>; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts index 1631711cd633..b808211be2c0 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts @@ -1,7 +1,8 @@ +import slugify from 'slug'; import { fullEntitiesList, storeLoadById } from '../../database/middleware-loader'; import type { AuthContext, AuthUser } from '../../types/user'; import { ENTITY_TYPE_CUSTOM_VIEW, type BasicStoreEntityCustomView } from './customView-types'; -import { FilterMode } from '../../generated/graphql'; +import { FilterMode, type CustomViewAddInput } from '../../generated/graphql'; import { ENTITY_TYPE_CONTAINER_NOTE, ENTITY_TYPE_CONTAINER_OBSERVED_DATA, @@ -16,6 +17,8 @@ import { ENTITY_TYPE_CONTAINER_FEEDBACK } from '../case/feedback/feedback-types' import { ABSTRACT_STIX_CORE_RELATIONSHIP, ABSTRACT_STIX_CYBER_OBSERVABLE, ABSTRACT_STIX_DOMAIN_OBJECT } from '../../schema/general'; import { schemaTypesDefinition } from '../../schema/schema-types'; import { ENTITY_HASHED_OBSERVABLE_ARTIFACT } from '../../schema/stixCyberObservable'; +import { createEntity } from '../../database/middleware'; +import { now } from '../../utils/format'; /** * Exclusion list: entity types not capable of @@ -126,3 +129,24 @@ export const getCustomViewsSettings = async ( customViews: customViewEntities, }; }; + +export const addCustomView = async ( + context: AuthContext, + user: AuthUser, + input: CustomViewAddInput, +) => { + const created_at = now(); + const customViewToCreate = { + ...input, + slug: slugify(input.name), + created_at, + updated_at: created_at, + }; + const entity = await createEntity( + context, + user, + customViewToCreate, + ENTITY_TYPE_CUSTOM_VIEW, + ); + return entity; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts index 36410b526436..268dfb6b59b3 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts @@ -1,5 +1,5 @@ import type { Resolvers } from '../../generated/graphql'; -import { getCustomViewsSettings, getCustomViewByIdForDisplay, getCustomViewsDisplayContext, computeCustomViewPath } from './customView-domain'; +import { addCustomView, getCustomViewsSettings, getCustomViewByIdForDisplay, getCustomViewsDisplayContext, computeCustomViewPath } from './customView-domain'; const customViewResolver: Resolvers = { Query: { @@ -18,7 +18,11 @@ const customViewResolver: Resolvers = { return computeCustomViewPath(customView); }, }, - Mutation: {}, + Mutation: { + customViewAdd: (_, { input }, context) => { + return addCustomView(context, context.user, input); + }, + }, }; export default customViewResolver; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql index 8afbbd1d1b6a..ea1cf0eb461b 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql @@ -25,6 +25,13 @@ type CustomViewsSettings { customViews: [CustomView!]! } +input CustomViewAddInput { + name: String! @constraint(minLength: 2, format: "not-blank") + description: String + manifest: String + target_entity_type: String! +} + # Queries type Query { ## Platform user use cases @@ -44,3 +51,12 @@ type Query { entityType: String! ): CustomViewsSettings! @auth(for: [SETTINGS_SETCUSTOMIZATION]) @ff(flags: ["CUSTOM_VIEW"]) } + +# Mutations +type Mutation { + ## Administration use cases + + # Add a new custom view + customViewAdd(input: CustomViewAddInput!): CustomView! @auth(for: [SETTINGS_SETCUSTOMIZATION]) +} + diff --git a/opencti-platform/opencti-graphql/yarn.lock b/opencti-platform/opencti-graphql/yarn.lock index 8c7b5169589d..cfe691a434f1 100644 --- a/opencti-platform/opencti-graphql/yarn.lock +++ b/opencti-platform/opencti-graphql/yarn.lock @@ -4974,6 +4974,13 @@ __metadata: languageName: node linkType: hard +"@types/slug@npm:5.0.9": + version: 5.0.9 + resolution: "@types/slug@npm:5.0.9" + checksum: 10c0/6d5366d80d83a8d08b7d33ea14394511997de2d6001d1e463a5141aa10bf0dd1ec801e0e248b8a84239142064f96918638db7ca2c745c85891d6c34c309ad3a6 + languageName: node + linkType: hard + "@types/triple-beam@npm:^1.3.2": version: 1.3.5 resolution: "@types/triple-beam@npm:1.3.5" @@ -10817,6 +10824,7 @@ __metadata: "@types/passport-local": "npm:1.0.38" "@types/ramda": "npm:0.31.1" "@types/semver": "npm:7.7.1" + "@types/slug": "npm:5.0.9" "@types/turndown": "npm:5.0.6" "@types/validator": "npm:13.15.10" "@types/xml2js": "npm:0.4.14" From 53c3181b681b8a84d19959480c965aa1de90c2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Sun, 19 Apr 2026 02:25:00 +0200 Subject: [PATCH 03/17] [frontend] Add a custom view --- .../opencti-front/lang/front/de.json | 3 + .../opencti-front/lang/front/en.json | 3 + .../opencti-front/lang/front/es.json | 3 + .../opencti-front/lang/front/fr.json | 3 + .../opencti-front/lang/front/it.json | 3 + .../opencti-front/lang/front/ja.json | 3 + .../opencti-front/lang/front/ko.json | 3 + .../opencti-front/lang/front/ru.json | 3 + .../opencti-front/lang/front/zh.json | 3 + .../sub_types/custom_views/CustomViewForm.tsx | 92 +++++++++++++++++++ .../custom_views/CustomViewFormDrawer.tsx | 71 ++++++++++++++ .../custom_views/CustomViewsSettings.tsx | 47 +++++++--- .../custom_views/useCustomViewAdd.ts | 72 +++++++++++++++ 13 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewAdd.ts diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index ff29da7e122c..16bed1e87add 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -796,6 +796,7 @@ "Create a narrative": "Eine Erzählung erstellen", "Create a nested relationship": "Eine verschachtelte Beziehung erstellen", "Create a new container at each run": "Bei jedem Lauf einen neuen Container erstellen", + "Create a new custom view": "Erstellen Sie eine neue benutzerdefinierte Ansicht", "Create a new file with the content": "Erstellen Sie eine neue Datei mit dem Inhalt", "Create a new public dashboard": "Erstellen Sie ein neues öffentliches Dashboard", "Create a new template": "Erstellen Sie eine neue Vorlage", @@ -867,6 +868,7 @@ "Create as draft": "Erstellen als Entwurf", "Create as draft by default": "Standardmäßig als Entwurf erstellen", "Create Authentication": "Authentifizierung erstellen", + "Create custom view": "Benutzerdefinierte Ansicht erstellen", "Create dashboard": "Dashboard erstellen", "Create external reference at upload": "Externe Referenz beim Upload erstellen", "Create file": "Datei erstellen", @@ -950,6 +952,7 @@ "Custom dashboards": "Benutzerdefinierte Dashboards", "Custom dashboards | Dashboards": "Benutzerdefinierte Dashboards | Dashboards", "Custom view": "Benutzerdefinierte Ansicht", + "Custom view created": "Benutzerdefinierte Ansicht erstellt", "Custom Views": "Benutzerdefinierte Ansichten", "Customization": "Anpassungen", "Customize columns": "Spalten anpassen", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 0873676781c7..5679eec68208 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -796,6 +796,7 @@ "Create a narrative": "Create a narrative", "Create a nested relationship": "Create a nested relationship", "Create a new container at each run": "Create a new container at each run", + "Create a new custom view": "Create a new custom view", "Create a new file with the content": "Create a new file with the content", "Create a new public dashboard": "Create a new public dashboard", "Create a new template": "Create a new template", @@ -867,6 +868,7 @@ "Create as draft": "Create as draft", "Create as draft by default": "Create as draft by default", "Create Authentication": "Create Authentication", + "Create custom view": "Create custom view", "Create dashboard": "Create dashboard", "Create external reference at upload": "Create external reference at upload", "Create file": "Create file", @@ -950,6 +952,7 @@ "Custom dashboards": "Custom dashboards", "Custom dashboards | Dashboards": "Custom dashboards | dashboards", "Custom view": "Custom view", + "Custom view created": "Custom view created", "Custom Views": "Custom Views", "Customization": "Customization", "Customize columns": "Customize columns", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index 82749cfae4b4..d19f6fa1db78 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -796,6 +796,7 @@ "Create a narrative": "Crear una narrativa", "Create a nested relationship": "Crear una relación anidada", "Create a new container at each run": "Crear un nuevo contenedor en cada ejecución", + "Create a new custom view": "Crear una nueva vista personalizada", "Create a new file with the content": "Crear un nuevo archivo con el contenido", "Create a new public dashboard": "Crear un nuevo panel público", "Create a new template": "Crear una nueva plantilla", @@ -867,6 +868,7 @@ "Create as draft": "Crear como borrador", "Create as draft by default": "Crear como borrador por defecto", "Create Authentication": "Crear autenticación", + "Create custom view": "Crear vista personalizada", "Create dashboard": "Crear un cuadro de mando", "Create external reference at upload": "Crear referencia externa al cargar", "Create file": "Crear archivo", @@ -950,6 +952,7 @@ "Custom dashboards": "Cuadros de mando personalizados", "Custom dashboards | Dashboards": "Cuadros de mando personalizados | Cuadros de mando", "Custom view": "Vista personalizada", + "Custom view created": "Vista personalizada creada", "Custom Views": "Vistas personalizadas", "Customization": "Personalización", "Customize columns": "Personalizar columnas", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index 2259d007323a..edc824663225 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -796,6 +796,7 @@ "Create a narrative": "Créer un narratif", "Create a nested relationship": "Créer une relation imbriquée", "Create a new container at each run": "Créer un nouveau conteneur à chaque exécution", + "Create a new custom view": "Créer une nouvelle vue personnalisée", "Create a new file with the content": "Créer un nouveau fichier avec le contenu", "Create a new public dashboard": "Créer un nouveau tableau de bord public", "Create a new template": "Créer un nouveau modèle", @@ -867,6 +868,7 @@ "Create as draft": "Créer un projet", "Create as draft by default": "Créer en tant que brouillon par défaut", "Create Authentication": "Créer une authentification", + "Create custom view": "Créer une vue personnalisée", "Create dashboard": "Créer un tableau de bord", "Create external reference at upload": "Créer une référence externe lors du téléchargement", "Create file": "Créer un fichier", @@ -950,6 +952,7 @@ "Custom dashboards": "Tableaux de bord personnalisés", "Custom dashboards | Dashboards": "Tableaux de bord personnalisés", "Custom view": "Vue personnalisée", + "Custom view created": "Vue personnalisée créée", "Custom Views": "Vues personnalisées", "Customization": "Personnalisation", "Customize columns": "Personnaliser les colonnes", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index b9ea829dc8f7..a5ede42f6bab 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -796,6 +796,7 @@ "Create a narrative": "Crea una storia", "Create a nested relationship": "Crea una relazione nidificata", "Create a new container at each run": "Creare un nuovo contenitore a ogni esecuzione", + "Create a new custom view": "Creare una nuova vista personalizzata", "Create a new file with the content": "Crea un nuovo file con il contenuto", "Create a new public dashboard": "Crea una nuova dashboard pubblica", "Create a new template": "Crea un nuovo template", @@ -867,6 +868,7 @@ "Create as draft": "Crea come bozza", "Create as draft by default": "Creare come bozza per impostazione predefinita", "Create Authentication": "Creare l'autenticazione", + "Create custom view": "Creare una vista personalizzata", "Create dashboard": "Crea una dashboard", "Create external reference at upload": "Crea un riferimento esterno al caricamento", "Create file": "Crea file", @@ -950,6 +952,7 @@ "Custom dashboards": "Dashboard personalizzate", "Custom dashboards | Dashboards": "Dashboard personalizzate | Dashboard", "Custom view": "Vista personalizzata", + "Custom view created": "Vista personalizzata creata", "Custom Views": "Viste personalizzate", "Customization": "Personalizzazione", "Customize columns": "Personalizza le colonne", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 52ae42cf981e..1b6d59f0cc3b 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -796,6 +796,7 @@ "Create a narrative": "ナラティブを作成", "Create a nested relationship": "階層化されたリレーションシップを作成する", "Create a new container at each run": "実行のたびに新しいコンテナを作成する", + "Create a new custom view": "新しいカスタムビューを作成する", "Create a new file with the content": "コンテンツの新規ファイルを作成する", "Create a new public dashboard": "新しい公開ダッシュボードを作成する", "Create a new template": "新しいテンプレートの作成", @@ -867,6 +868,7 @@ "Create as draft": "下書きとして作成", "Create as draft by default": "デフォルトでドラフトとして作成", "Create Authentication": "認証の作成", + "Create custom view": "カスタムビューの作成", "Create dashboard": "ダッシュボードの作成", "Create external reference at upload": "アップロード時に外部参照を作成", "Create file": "ファイル作成", @@ -950,6 +952,7 @@ "Custom dashboards": "カスタムダッシュボード", "Custom dashboards | Dashboards": "カスタムダッシュボード|ダッシュボード", "Custom view": "カスタムビュー", + "Custom view created": "カスタムビューの作成", "Custom Views": "カスタムビュー", "Customization": "カスタマイズ", "Customize columns": "コラムのカスタマイズ", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index 6d2ca6ea4852..e701b76dd2da 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -796,6 +796,7 @@ "Create a narrative": "서사 생성", "Create a nested relationship": "중첩된 관계 생성", "Create a new container at each run": "실행할 때마다 새 컨테이너 생성", + "Create a new custom view": "새 사용자 지정 뷰 만들기", "Create a new file with the content": "내용으로 새 파일 생성", "Create a new public dashboard": "새 공개 대시보드 생성", "Create a new template": "새 템플릿 만들기", @@ -867,6 +868,7 @@ "Create as draft": "초안으로 만들기", "Create as draft by default": "기본적으로 초안으로 생성", "Create Authentication": "인증 만들기", + "Create custom view": "사용자 지정 보기 만들기", "Create dashboard": "대시보드 생성", "Create external reference at upload": "업로드 시 외부 참조 생성", "Create file": "파일 만들기", @@ -950,6 +952,7 @@ "Custom dashboards": "맞춤 대시보드", "Custom dashboards | Dashboards": "사용자 지정 대시보드 | 대시보드", "Custom view": "사용자 지정 보기", + "Custom view created": "사용자 지정 뷰 생성", "Custom Views": "사용자 지정 뷰", "Customization": "맞춤 설정", "Customize columns": "열 사용자 지정", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index c71e55533ae7..34ff1156b9cb 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -796,6 +796,7 @@ "Create a narrative": "Создайте повествование", "Create a nested relationship": "Создайте вложенные отношения", "Create a new container at each run": "Создавайте новый контейнер при каждом запуске", + "Create a new custom view": "Создайте новый пользовательский вид", "Create a new file with the content": "Создайте новый файл с содержимым", "Create a new public dashboard": "Создайте новый общедоступный дашборд", "Create a new template": "Создайте новый шаблон", @@ -867,6 +868,7 @@ "Create as draft": "Создать как проект", "Create as draft by default": "Создавать по умолчанию как черновик", "Create Authentication": "Создание аутентификации", + "Create custom view": "Создание пользовательского представления", "Create dashboard": "Создайте дашборд", "Create external reference at upload": "Создание внешней ссылки при загрузке", "Create file": "Создать файл", @@ -950,6 +952,7 @@ "Custom dashboards": "Пользовательские дашборды", "Custom dashboards | Dashboards": "Пользовательские панели | Панели инструментов", "Custom view": "Пользовательское представление", + "Custom view created": "Создано пользовательское представление", "Custom Views": "Пользовательские представления", "Customization": "Настройка", "Customize columns": "Настройте столбцы", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index c723bf4b1de8..1de340c7800a 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -796,6 +796,7 @@ "Create a narrative": "创建叙述", "Create a nested relationship": "创建嵌套关系", "Create a new container at each run": "每次运行时创建一个新容器", + "Create a new custom view": "创建新的自定义视图", "Create a new file with the content": "创建一个包含内容的新文件", "Create a new public dashboard": "创建新的公共仪表盘", "Create a new template": "创建新模板", @@ -867,6 +868,7 @@ "Create as draft": "创建为草稿", "Create as draft by default": "默认创建为草稿", "Create Authentication": "创建身份验证", + "Create custom view": "创建自定义视图", "Create dashboard": "创建仪表板", "Create external reference at upload": "上传时创建外部参考", "Create file": "创建文件", @@ -950,6 +952,7 @@ "Custom dashboards": "自定义仪表盘", "Custom dashboards | Dashboards": "自定义仪表盘", "Custom view": "自定义视图", + "Custom view created": "创建自定义视图", "Custom Views": "自定义视图", "Customization": "定制化", "Customize columns": "自定义列", diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx new file mode 100644 index 000000000000..bf24fb57e86c --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx @@ -0,0 +1,92 @@ +import Button from '@common/button/Button'; +import { Field, Form, Formik } from 'formik'; +import { FormikConfig } from 'formik/dist/types'; +import * as Yup from 'yup'; +import TextField from '../../../../../components/TextField'; +import FormButtonContainer from '../../../../../components/common/form/FormButtonContainer'; +import MarkdownField from '../../../../../components/fields/markdownField/MarkdownField'; +import { useFormatter } from '../../../../../components/i18n'; +import { fieldSpacingContainerStyle } from '../../../../../utils/field'; + +export interface CustomViewFormInputs { + name: string; + description: string | null; +} + +export type CustomViewFormInputKeys = keyof CustomViewFormInputs; + +interface CustomViewFormProps { + onClose: () => void; + onSubmit: FormikConfig['onSubmit']; + defaultValues?: CustomViewFormInputs; +} + +const CustomViewForm = ({ + onClose, + onSubmit, + defaultValues, +}: CustomViewFormProps) => { + const { t_i18n } = useFormatter(); + + const validation = Yup.object().shape({ + name: Yup.string().trim().required(t_i18n('This field is required')), + description: Yup.string().nullable(), + }); + + const initialValues: CustomViewFormInputs = defaultValues ?? { + name: '', + description: null, + }; + + return ( + + enableReinitialize={true} + validationSchema={validation} + initialValues={initialValues} + onSubmit={onSubmit} + > + {({ submitForm, handleReset, isSubmitting }) => { + return ( +
+ + + + + + + + ); + }} + + ); +}; + +export default CustomViewForm; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx new file mode 100644 index 000000000000..9182b2047c0e --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx @@ -0,0 +1,71 @@ +import { FormikConfig } from 'formik/dist/types'; +import { useNavigate } from 'react-router-dom'; +import Drawer from '@components/common/drawer/Drawer'; +import useCustomViewAdd from './useCustomViewAdd'; +import CustomViewForm, { type CustomViewFormInputs } from './CustomViewForm'; +import { useFormatter } from '../../../../../components/i18n'; +import { handleError, MESSAGING$ } from '../../../../../relay/environment'; + +interface CustomViewFormDrawerProps { + isOpen: boolean; + onClose: () => void; + entityType: string; +} + +const CustomViewFormDrawer = ({ + isOpen, + onClose, + entityType, +}: CustomViewFormDrawerProps) => { + const navigate = useNavigate(); + const { t_i18n } = useFormatter(); + const createTitle = t_i18n('Create custom view'); + + const [commitAddMutation] = useCustomViewAdd(); + + const onAdd: FormikConfig['onSubmit'] = ( + values, + { setSubmitting, resetForm }, + ) => { + commitAddMutation({ + variables: { + input: { + name: values.name, + description: values.description, + target_entity_type: entityType, + }, + }, + onCompleted: (response) => { + setSubmitting(false); + resetForm(); + onClose(); + if (response.customViewAdd) { + const { id } = response.customViewAdd; + MESSAGING$.notifySuccess(t_i18n('Custom view created')); + navigate(`/dashboard/settings/customization/entity_types/${entityType}/custom-views/${id}`); + } + }, + onError: (error) => { + setSubmitting(false); + handleError(error); + }, + }); + }; + + return ( + <> + + + + + ); +}; + +export default CustomViewFormDrawer; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettings.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettings.tsx index bfcf248e5068..ae76f6b89980 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettings.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettings.tsx @@ -1,10 +1,15 @@ +import { useState } from 'react'; import { graphql, useFragment } from 'react-relay'; import Grid from '@mui/material/Grid'; import Card from '@common/card/Card'; +import Tooltip from '@mui/material/Tooltip'; +import { Add as AddIcon } from '@mui/icons-material'; +import IconButton from '@common/button/IconButton'; import { useFormatter } from '../../../../../components/i18n'; import { useSubTypeOutletContext } from '../SubTypeOutletContext'; import CustomViewsSettingsDataTable from './CustomViewsSettingsDataTable'; import { CustomViewsSettings_customViews$key } from './__generated__/CustomViewsSettings_customViews.graphql'; +import CustomViewFormDrawer from './CustomViewFormDrawer'; const customViewsFragment = graphql` fragment CustomViewsSettings_customViews on CustomViewsSettings { @@ -24,24 +29,44 @@ const customViewsFragment = graphql` const CustomViewsSettings = () => { const { t_i18n } = useFormatter(); const { customViewsSettings, subType } = useSubTypeOutletContext(); - const { customViews } = useFragment( + const { customViews } = useFragment( customViewsFragment, - customViewsSettings as CustomViewsSettings_customViews$key, + customViewsSettings, ); + const [isDrawerOpen, setDrawerOpen] = useState(false); return ( - - + + + setDrawerOpen(true)} + size="small" + > + + + + )} + > + + + + { + setDrawerOpen(false); }} - > - - - + /> + ); }; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewAdd.ts b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewAdd.ts new file mode 100644 index 000000000000..f808ec58e725 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewAdd.ts @@ -0,0 +1,72 @@ +import { graphql } from 'react-relay'; +import { useState } from 'react'; +import { RecordSourceSelectorProxy } from 'relay-runtime'; +import { useCustomViewAdd_Mutation, useCustomViewAdd_Mutation$data } from './__generated__/useCustomViewAdd_Mutation.graphql'; +import useApiMutation from '../../../../../utils/hooks/useApiMutation'; + +const customViewAddMutation = graphql` + mutation useCustomViewAdd_Mutation($input: CustomViewAddInput!) { + customViewAdd(input: $input) { + id + name + path + target_entity_type + } + } +`; + +/** + * Updates the Relay store after a successful custom view creation in order + * to insert a new edge in the `customViewsDisplayContext` structure so that + * the admin user can check out the new custom view in the entity page + * without the need to refresh. + */ +const customViewsDisplayContextUpdater = ( + store: RecordSourceSelectorProxy, + data: useCustomViewAdd_Mutation$data | null | undefined, +) => { + const result = data as useCustomViewAdd_Mutation$data; + const { target_entity_type, id } = result.customViewAdd; + const root = store.getRoot(); + const displayContextRecords = root.getLinkedRecords('customViewsDisplayContext') ?? []; + let targetRecord = displayContextRecords.find( + (r) => r.getValue('entity_type') === target_entity_type, + ); + if (!targetRecord) { + const newDisplayContext = `CustomViewsDisplayContext:${target_entity_type}`; + targetRecord = store.create(newDisplayContext, 'CustomViewsDisplayContext'); + targetRecord.setValue(target_entity_type, 'entity_type'); + root.setLinkedRecords([...displayContextRecords, targetRecord], 'customViewsDisplayContext'); + } + const existingInfos = targetRecord.getLinkedRecords('custom_views_info') ?? []; + const newInfoRecord = store.get(id)!; + targetRecord.setLinkedRecords([...existingInfos, newInfoRecord], 'custom_views_info'); +}; + +/** + * Hook handling Custom view creation logic + */ +const useCustomViewAdd = () => { + const [mutating, setMutating] = useState(false); + const [commitAddMutation] = useApiMutation(customViewAddMutation); + + const mutation: typeof commitAddMutation = ({ variables, onCompleted, onError }) => { + setMutating(true); + commitAddMutation({ + variables, + updater: customViewsDisplayContextUpdater, + onError: (error) => { + setMutating(false); + onError?.(error); + }, + onCompleted: (...args) => { + setMutating(false); + onCompleted?.(...args); + }, + }); + }; + + return [mutation, mutating] as const; +}; + +export default useCustomViewAdd; From 1195df6b77a7e12d225b093939f8bb21dc696aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Fri, 17 Apr 2026 13:39:54 +0200 Subject: [PATCH 04/17] [frontend] Enable getting a ref on underlying component of a TextField --- opencti-platform/opencti-front/src/components/TextField.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/opencti-platform/opencti-front/src/components/TextField.tsx b/opencti-platform/opencti-front/src/components/TextField.tsx index f6877d446b74..49f3ec016c71 100644 --- a/opencti-platform/opencti-front/src/components/TextField.tsx +++ b/opencti-platform/opencti-front/src/components/TextField.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, ClipboardEvent, FocusEvent, KeyboardEvent, ReactNode, useCallback } from 'react'; +import React, { ChangeEvent, ClipboardEvent, FocusEvent, KeyboardEvent, ReactNode, Ref, useCallback } from 'react'; import { TextField as MuiTextField, TextFieldProps as MuiTextFieldProps } from '@mui/material'; import { fieldToTextField } from 'formik-mui'; import { FieldProps, useField } from 'formik'; @@ -16,6 +16,7 @@ export type TextFieldProps = FieldProps & MuiTextFieldProps & { onSubmit?: (name: string, value: string) => void; onKeyDown?: (key: string) => void; onBeforePaste?: (value: string) => string; + inputRef?: Ref; }; const TextField = (props: TextFieldProps) => { @@ -27,6 +28,7 @@ const TextField = (props: TextFieldProps) => { onFocus, onSubmit, onKeyDown, + inputRef, } = props; const { fullyActive } = useAI(); @@ -101,6 +103,7 @@ const TextField = (props: TextFieldProps) => { Date: Sun, 19 Apr 2026 02:37:40 +0200 Subject: [PATCH 05/17] [frontend] Focus on name field when creating custom view --- .../settings/sub_types/custom_views/CustomViewForm.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx index bf24fb57e86c..200af32a60b6 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx @@ -7,6 +7,7 @@ import FormButtonContainer from '../../../../../components/common/form/FormButto import MarkdownField from '../../../../../components/fields/markdownField/MarkdownField'; import { useFormatter } from '../../../../../components/i18n'; import { fieldSpacingContainerStyle } from '../../../../../utils/field'; +import { useEffect, useRef } from 'react'; export interface CustomViewFormInputs { name: string; @@ -37,6 +38,12 @@ const CustomViewForm = ({ name: '', description: null, }; + const nameInputRef = useRef(null); + useEffect(() => { + if (nameInputRef.current) { + nameInputRef.current.focus(); + } + }, []); return ( @@ -55,6 +62,7 @@ const CustomViewForm = ({ label={t_i18n('Name')} fullWidth={true} required + inputRef={nameInputRef} /> Date: Thu, 16 Apr 2026 13:50:47 +0200 Subject: [PATCH 06/17] [frontend] Prepare dashboard commons --- .../dashboard}/DashboardAuditsViz.tsx | 8 +- .../components/dashboard/DashboardContent.tsx | 92 +++++++ .../dashboard}/DashboardEntitiesViz.tsx | 8 +- .../dashboard}/DashboardRawViz.tsx | 4 +- .../dashboard}/DashboardRelationshipsViz.tsx | 8 +- .../dashboard}/DashboardTimeFilters.tsx | 25 +- .../dashboard}/DashboardViz.tsx | 6 +- .../dashboard/DashboardWidgetConfig.tsx} | 22 +- .../dashboard/DashboardWidgetPopover.jsx} | 23 +- .../components/dashboard/dashboard-types.ts | 20 ++ .../src/components/dashboard/useDashboard.ts | 231 ++++++++++++++++++ .../workspaces/WorkspaceShareList.tsx | 2 +- .../PublicDashboardLineActions.tsx | 2 +- .../components/workspaces/workspace-utils.ts | 9 + .../workspaceHeader/WorkspaceHeader.tsx | 4 +- .../src/public/components/PublicDashboard.tsx | 2 +- .../dashboard/PublicDashboardHeader.tsx | 2 +- .../dashboard/usePublicDashboardWidgets.tsx | 2 +- .../opencti-front/src/utils/dashboard.ts | 21 -- .../src/utils/filters/filtersUtils.tsx | 2 +- .../src/utils/widget/widget.d.ts | 4 +- 21 files changed, 411 insertions(+), 86 deletions(-) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards => components/dashboard}/DashboardAuditsViz.tsx (97%) create mode 100644 opencti-platform/opencti-front/src/components/dashboard/DashboardContent.tsx rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards => components/dashboard}/DashboardEntitiesViz.tsx (98%) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards => components/dashboard}/DashboardRawViz.tsx (77%) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards => components/dashboard}/DashboardRelationshipsViz.tsx (98%) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards => components/dashboard}/DashboardTimeFilters.tsx (80%) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards => components/dashboard}/DashboardViz.tsx (86%) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards/WorkspaceWidgetConfig.tsx => components/dashboard/DashboardWidgetConfig.tsx} (77%) rename opencti-platform/opencti-front/src/{private/components/workspaces/dashboards/WorkspaceWidgetPopover.jsx => components/dashboard/DashboardWidgetPopover.jsx} (81%) create mode 100644 opencti-platform/opencti-front/src/components/dashboard/dashboard-types.ts create mode 100644 opencti-platform/opencti-front/src/components/dashboard/useDashboard.ts create mode 100644 opencti-platform/opencti-front/src/private/components/workspaces/workspace-utils.ts delete mode 100644 opencti-platform/opencti-front/src/utils/dashboard.ts diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardAuditsViz.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardAuditsViz.tsx similarity index 97% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardAuditsViz.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardAuditsViz.tsx index 80ff44ef26e9..a901efed849b 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardAuditsViz.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardAuditsViz.tsx @@ -13,10 +13,10 @@ import AuditsRadar from '@components/common/audits/AuditsRadar'; import AuditsMultiHeatMap from '@components/common/audits/AuditsMultiHeatMap'; import AuditsTreeMap from '@components/common/audits/AuditsTreeMap'; import AuditsWordCloud from '@components/common/audits/AuditsWordCloud'; -import { computerRelativeDate, dayStartDate, formatDate } from '../../../../utils/Time'; -import type { Widget } from '../../../../utils/widget/widget'; -import { useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../../../utils/filters/filtersUtils'; -import type { DashboardConfig } from '../../../../utils/dashboard'; +import { computerRelativeDate, dayStartDate, formatDate } from '../../utils/Time'; +import type { Widget } from '../../utils/widget/widget'; +import { useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../utils/filters/filtersUtils'; +import type { DashboardConfig } from './dashboard-types'; interface DashboardAuditsVizProps { widget: Widget; diff --git a/opencti-platform/opencti-front/src/components/dashboard/DashboardContent.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardContent.tsx new file mode 100644 index 000000000000..c00b9cf27959 --- /dev/null +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardContent.tsx @@ -0,0 +1,92 @@ +import ReactGridLayout, { useContainerWidth } from 'react-grid-layout'; +import Box from '@mui/material/Box'; +import useDashboard from './useDashboard'; +import DashboardWidgetPopover from './DashboardWidgetPopover'; +import DashboardViz from './DashboardViz'; +import type { DashboardLike } from './dashboard-types'; + +interface DashboardContentProps { + dashboardEntity: DashboardLike; + isEditable: boolean; + helpers: ReturnType; +} + +const DashboardContent = ({ + dashboardEntity, + isEditable, + helpers: { + widgetsLayouts, + widgetsArray, + handleUpdateWidget, + handleLayoutChange, + handleResize, + handleDuplicateWidget, + handleDeleteWidget, + handleExportWidget, + idToResize, + config, + }, +}: DashboardContentProps) => { + const { width, containerRef } = useContainerWidth(); + return ( + + true} + onResizeStart={isEditable ? (_, layoutItem) => handleResize(layoutItem?.i ?? null) : undefined} + onResizeStop={isEditable ? () => handleResize(null) : undefined} + > + {widgetsArray.map((widget) => { + if (!widgetsLayouts[widget.id]) return null; + const popover = isEditable && ( + handleDeleteWidget(widget.id)} + onExport={handleExportWidget} + /> + ); + + return ( +
+ {isEditable && widget.id === idToResize ?
: ( + + )} +
+ ); + })} + + + ); +}; + +export default DashboardContent; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardEntitiesViz.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardEntitiesViz.tsx similarity index 98% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardEntitiesViz.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardEntitiesViz.tsx index 8346a7763b79..2498de404242 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardEntitiesViz.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardEntitiesViz.tsx @@ -16,10 +16,10 @@ import StixCoreObjectsRadar from '@components/common/stix_core_objects/StixCoreO import StixCoreObjectsMultiHeatMap from '@components/common/stix_core_objects/StixCoreObjectsMultiHeatMap'; import StixCoreObjectsTreeMap from '@components/common/stix_core_objects/StixCoreObjectsTreeMap'; import StixCoreObjectsWordCloud from '@components/common/stix_core_objects/StixCoreObjectsWordCloud'; -import type { Widget } from '../../../../utils/widget/widget'; -import { computerRelativeDate, dayStartDate, formatDate } from '../../../../utils/Time'; -import { useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../../../utils/filters/filtersUtils'; -import type { DashboardConfig } from '../../../../utils/dashboard'; +import type { Widget } from '../../utils/widget/widget'; +import { computerRelativeDate, dayStartDate, formatDate } from '../../utils/Time'; +import { useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../utils/filters/filtersUtils'; +import type { DashboardConfig } from './dashboard-types'; interface DashboardEntitiesVizProps { widget: Widget; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardRawViz.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardRawViz.tsx similarity index 77% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardRawViz.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardRawViz.tsx index 4f3ab23bb4a1..8f95a812abd2 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardRawViz.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardRawViz.tsx @@ -1,6 +1,6 @@ import { memo, ReactNode } from 'react'; -import WidgetText from '../../../../components/dashboard/WidgetText'; -import type { Widget } from '../../../../utils/widget/widget'; +import WidgetText from './WidgetText'; +import type { Widget } from '../../utils/widget/widget'; interface DashboardRawVizProps { widget: Widget; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardRelationshipsViz.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardRelationshipsViz.tsx similarity index 98% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardRelationshipsViz.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardRelationshipsViz.tsx index 1ffbe5502190..4ddd0b69955f 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardRelationshipsViz.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardRelationshipsViz.tsx @@ -16,10 +16,10 @@ import StixRelationshipsMultiHeatMap from '@components/common/stix_relationships import StixRelationshipsTreeMap from '@components/common/stix_relationships/StixRelationshipsTreeMap'; import StixRelationshipsMap from '@components/common/stix_relationships/StixRelationshipsMap'; import StixRelationshipsWordCloud from '@components/common/stix_relationships/StixRelationshipsWordCloud'; -import { computerRelativeDate, dayStartDate, formatDate } from '../../../../utils/Time'; -import type { Widget, WidgetDataSelection } from '../../../../utils/widget/widget'; -import { useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../../../utils/filters/filtersUtils'; -import type { DashboardConfig } from '../../../../utils/dashboard'; +import { computerRelativeDate, dayStartDate, formatDate } from '../../utils/Time'; +import type { Widget, WidgetDataSelection } from '../../utils/widget/widget'; +import { useRemoveIdAndIncorrectKeysFromFilterGroupObject } from '../../utils/filters/filtersUtils'; +import type { DashboardConfig } from './dashboard-types'; interface DashboardRelationshipsVizProps { widget: Widget; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardTimeFilters.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx similarity index 80% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardTimeFilters.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx index 81b4162f95e7..2b6a724cc311 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardTimeFilters.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx @@ -5,28 +5,23 @@ import Select, { SelectChangeEvent } from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import DatePicker from '@common/input/DatePicker'; import { parse, buildDate } from 'src/utils/Time'; -import { InvestigationGraph_fragment$data } from '@components/workspaces/investigations/__generated__/InvestigationGraph_fragment.graphql'; -import { EXPLORE_EXUPDATE, INVESTIGATION_INUPDATE } from '../../../../utils/hooks/useGranted'; -import Security from '../../../../utils/Security'; -import { useFormatter } from '../../../../components/i18n'; -import { useGetCurrentUserAccessRight } from '../../../../utils/authorizedMembers'; -import { Dashboard_workspace$data } from './__generated__/Dashboard_workspace.graphql'; +import { EXPLORE_EXUPDATE, INVESTIGATION_INUPDATE } from '../../utils/hooks/useGranted'; +import Security from '../../utils/Security'; +import { useFormatter } from '../i18n'; +import { useGetCurrentUserAccessRight } from '../../utils/authorizedMembers'; import { Stack } from '@mui/material'; import { useTheme } from '@mui/styles'; -import { Theme } from '../../../../components/Theme'; +import { Theme } from '../Theme'; +import { DashboardConfig } from './dashboard-types'; interface DashboardTimeFiltersProps { - workspace: Dashboard_workspace$data | InvestigationGraph_fragment$data; - config?: { - startDate: string | null; - endDate: string | null; - relativeDate: string | null; - }; + currentUserAccessRight: string | null | undefined; + config?: DashboardConfig; handleDateChange: (type: 'startDate' | 'endDate' | 'relativeDate', value: string | null) => void; } const DashboardTimeFilters: React.FC = ({ - workspace, + currentUserAccessRight, config = { startDate: null, endDate: null, @@ -36,7 +31,7 @@ const DashboardTimeFilters: React.FC = ({ }) => { const { t_i18n } = useFormatter(); const theme = useTheme(); - const { canEdit } = useGetCurrentUserAccessRight(workspace.currentUserAccessRight); + const { canEdit } = useGetCurrentUserAccessRight(currentUserAccessRight); const handleChangeRelativeDate = (event: SelectChangeEvent) => { const { value } = event.target; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardViz.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardViz.tsx similarity index 86% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardViz.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardViz.tsx index 0f96ba7763d8..c8f57fef28c0 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/DashboardViz.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardViz.tsx @@ -1,11 +1,11 @@ import { ReactNode } from 'react'; -import { ErrorBoundary } from '../../Error'; -import type { Widget } from '../../../../utils/widget/widget'; +import { ErrorBoundary } from '../../private/components/Error'; +import type { Widget } from '../../utils/widget/widget'; import DashboardRawViz from './DashboardRawViz'; import DashboardRelationshipsViz from './DashboardRelationshipsViz'; import DashboardAuditsViz from './DashboardAuditsViz'; import DashboardEntitiesViz from './DashboardEntitiesViz'; -import type { DashboardConfig } from '../../../../utils/dashboard'; +import type { DashboardConfig } from './dashboard-types'; interface DashboardVizProps { widget: Widget; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/WorkspaceWidgetConfig.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardWidgetConfig.tsx similarity index 77% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/WorkspaceWidgetConfig.tsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardWidgetConfig.tsx index a511a9588f3b..0603ee7cb607 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/WorkspaceWidgetConfig.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardWidgetConfig.tsx @@ -1,21 +1,21 @@ import React, { useRef, useState } from 'react'; import Button from '@common/button/Button'; import MenuItem from '@mui/material/MenuItem'; -import { Widget } from 'src/utils/widget/widget'; -import VisuallyHiddenInput from '../../common/VisuallyHiddenInput'; -import WidgetConfig from '../../widgets/WidgetConfig'; -import Security from '../../../../utils/Security'; -import { EXPLORE_EXUPDATE } from '../../../../utils/hooks/useGranted'; -import { useFormatter } from '../../../../components/i18n'; +import VisuallyHiddenInput from '../../private/components/common/VisuallyHiddenInput'; +import WidgetConfig from '../../private/components/widgets/WidgetConfig'; +import Security from '../../utils/Security'; +import { EXPLORE_EXUPDATE } from '../../utils/hooks/useGranted'; +import { useFormatter } from '../i18n'; +import type { DashboardWidget } from './dashboard-types'; type WorkspaceWidgetConfigProps = { handleImportWidget: (widgetFile: File) => void; - widget?: Widget; - onComplete: (value: Widget, variableName?: string) => void; + widget?: DashboardWidget; + onComplete: (value: DashboardWidget, variableName?: string) => void; closeMenu?: () => void; }; -const WorkspaceWidgetConfig = ({ widget, onComplete, closeMenu, handleImportWidget }: WorkspaceWidgetConfigProps) => { +const DashboardWidgetConfig = ({ widget, onComplete, closeMenu, handleImportWidget }: WorkspaceWidgetConfigProps) => { const { t_i18n } = useFormatter(); const [isWidgetConfigOpen, setIsWidgetConfigOpen] = useState(false); const inputRef: React.MutableRefObject = useRef(null); @@ -73,7 +73,7 @@ const WorkspaceWidgetConfig = ({ widget, onComplete, closeMenu, handleImportWidg )} onComplete(widget as DashboardWidget, variableName)} widget={widget} onClose={handleCloseWidgetConfig} open={isWidgetConfigOpen} @@ -83,4 +83,4 @@ const WorkspaceWidgetConfig = ({ widget, onComplete, closeMenu, handleImportWidg ); }; -export default WorkspaceWidgetConfig; +export default DashboardWidgetConfig; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/WorkspaceWidgetPopover.jsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardWidgetPopover.jsx similarity index 81% rename from opencti-platform/opencti-front/src/private/components/workspaces/dashboards/WorkspaceWidgetPopover.jsx rename to opencti-platform/opencti-front/src/components/dashboard/DashboardWidgetPopover.jsx index 2b5edad0e7b6..e254f64b3a31 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/WorkspaceWidgetPopover.jsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardWidgetPopover.jsx @@ -7,18 +7,18 @@ import DialogContentText from '@mui/material/DialogContentText'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; import { useState } from 'react'; -import DeleteDialog from '../../../../components/DeleteDialog'; -import { useFormatter } from '../../../../components/i18n'; -import Security from '../../../../utils/Security'; -import useDeletion from '../../../../utils/hooks/useDeletion'; -import { EXPLORE_EXUPDATE } from '../../../../utils/hooks/useGranted'; -import handleWidgetExportJson from '../../../../utils/widget/widgetExportHandler'; -import WorkspaceWidgetConfig from './WorkspaceWidgetConfig'; +import DeleteDialog from '../DeleteDialog'; +import { useFormatter } from '../i18n'; +import Security from '../../utils/Security'; +import useDeletion from '../../utils/hooks/useDeletion'; +import { EXPLORE_EXUPDATE } from '../../utils/hooks/useGranted'; +import DashboardWidgetConfig from './DashboardWidgetConfig'; -const WorkspaceWidgetPopover = ({ +const DashboardWidgetPopover = ({ onUpdate, onDuplicate, onDelete, + onExport, widget, workspace, }) => { @@ -39,7 +39,7 @@ const WorkspaceWidgetPopover = ({ }; const handleExportWidget = () => { - handleWidgetExportJson(workspace.id, widget); + onExport?.(workspace.id, widget); }; return ( @@ -66,11 +66,10 @@ const WorkspaceWidgetPopover = ({ className="noDrag" > - setAnchorEl(null)} onComplete={onUpdate} widget={widget} - data={workspace} /> {t_i18n('Export')} {t_i18n('Duplicate')} @@ -109,4 +108,4 @@ const WorkspaceWidgetPopover = ({ ); }; -export default WorkspaceWidgetPopover; +export default DashboardWidgetPopover; diff --git a/opencti-platform/opencti-front/src/components/dashboard/dashboard-types.ts b/opencti-platform/opencti-front/src/components/dashboard/dashboard-types.ts new file mode 100644 index 000000000000..0a1e8bbf6c16 --- /dev/null +++ b/opencti-platform/opencti-front/src/components/dashboard/dashboard-types.ts @@ -0,0 +1,20 @@ +import type { Widget } from '../../utils/widget/widget'; + +export interface DashboardConfig { + startDate?: string | null; + endDate?: string | null; + relativeDate?: string; +} + +// When used in dashboards widgets must have a layout +export type DashboardWidget = Widget & { layout: NonNullable }; + +export interface DashboardManifest { + config: DashboardConfig; + widgets: Record; +} + +export interface DashboardLike { + id: string; + manifest: string | undefined | null; +} diff --git a/opencti-platform/opencti-front/src/components/dashboard/useDashboard.ts b/opencti-platform/opencti-front/src/components/dashboard/useDashboard.ts new file mode 100644 index 000000000000..793757134107 --- /dev/null +++ b/opencti-platform/opencti-front/src/components/dashboard/useDashboard.ts @@ -0,0 +1,231 @@ +import { v4 as uuid } from 'uuid'; +import * as R from 'ramda'; +import { useEffect, useMemo, useState } from 'react'; +import fileDownload from 'js-file-download'; +import { fromB64, toB64 } from '../../utils/String'; +import { deserializeDashboardManifestForFrontend, serializeDashboardManifestForBackend } from '../../utils/filters/filtersUtils'; +import type { WidgetLayout } from '../../utils/widget/widget'; +import type { DashboardLike, DashboardManifest } from './dashboard-types'; + +interface useDashboardProps { + entity: DashboardLike | undefined | null; + onSave?: (entityId: string, newSerializedManifest: string, noRefresh: boolean, onCompleted: () => void) => void; + onImportWidget?: (entityId: string, widgetConfig: unknown, serializedManifest: string) => void; + onExportWidget?: (entityId: string, widget: { id: string; type: string }) => Promise; +} + +function useDashboard({ + entity, + onImportWidget, + onExportWidget, + onSave, +}: useDashboardProps) { + const serializedManifest = entity?.manifest; + const [deleting, setDeleting] = useState(false); + const [idToResize, setIdToResize] = useState(null); + const handleResize = (updatedWidgetId: string | null) => setIdToResize(updatedWidgetId); + + useEffect(() => { + const timeout = setTimeout(() => { + window.dispatchEvent(new Event('resize')); + }, 1200); + return () => { + clearTimeout(timeout); + }; + }, []); + + // Deserialized manifest, refreshed when workspace is updated. + const manifest = useMemo(() => { + return serializedManifest && serializedManifest.length > 0 + ? deserializeDashboardManifestForFrontend(fromB64(serializedManifest)) + : { widgets: {}, config: {} }; + }, [serializedManifest]); + + // Array of all widgets, refreshed when workspace is updated. + const widgetsArray = useMemo(() => Object.values(manifest.widgets), [manifest]); + + // Map of widget layouts, refreshed when workspace is updated (thanks to useMemo below). + // We use a local map of layouts to avoid a lot of computation when only changing position + // or dimension of widgets. + const [widgetsLayouts, setWidgetsLayouts] = useState>({}); + + useEffect(() => { + setWidgetsLayouts( + widgetsArray.reduce((res, widget) => { + res[widget.id] = widget.layout; + return res; + }, {} as Record), + ); + }, [widgetsArray]); + + /** + * Merge a manifest with some layouts and transform it in base64. + * + * @param newManifest Manifest to merge with local changes and stringify. + * @param layouts Local layout changes. + * @returns Manifest in B64. + */ + const prepareManifest = (newManifest: DashboardManifest, layouts: Record) => { + // Need to sync manifest with local layouts before sending for update. + // A desync occurs when resizing or moving a widget because in those cases + // we skip a complete reload to avoid performance issue. + const syncWidgets = Object.values(newManifest.widgets).reduce((res, widget) => { + const localLayout = layouts[widget.id]; + res[widget.id] = { + ...widget, + layout: localLayout || widget.layout, + }; + return res; + }, {} as DashboardManifest['widgets']); + const manifestToSave = { + ...newManifest, + widgets: syncWidgets, + }; + + const strManifest = serializeDashboardManifestForBackend(manifestToSave); + return toB64(strManifest); + }; + + const saveManifest = (newManifest: DashboardManifest, opts = { layouts: widgetsLayouts, noRefresh: false }) => { + const { layouts, noRefresh } = opts; + const newManifestEncoded = prepareManifest(newManifest, layouts); + // Sometimes (in case of layout adjustment) we do not want to re-fetch + // all the manifest because widgets data is still the same, and it's costly + // in performance. + if (serializedManifest !== newManifestEncoded) { + onSave?.(entity?.id ?? '', newManifestEncoded, noRefresh, () => { + setDeleting(false); + }); + } + }; + + const handleDateChange = (type: string, value: unknown) => { + let newManifest = { + ...manifest, + config: { + ...manifest.config, + [type]: value === 'none' ? null : value, + }, + }; + if (type === 'relativeDate' && value !== 'none') { + newManifest = { + ...newManifest, + config: { + ...newManifest.config, + startDate: null, + endDate: null, + }, + }; + } + saveManifest(newManifest); + }; + + const getNextRow = () => { + return widgetsArray.reduce((max, { layout }) => { + const widgetEndRow = layout.y + layout.h; + return widgetEndRow > max ? widgetEndRow : max; + }, 0); + }; + + const handleImportWidget = (widgetConfig: unknown) => { + const manifestEncoded = prepareManifest(manifest, widgetsLayouts); + onImportWidget?.(entity?.id ?? '', widgetConfig, manifestEncoded); + }; + + const handleExportWidget = async (id: string, widget: { id: string; type: string }) => { + onExportWidget?.(id, widget) + .then((exportedWidget: string) => { + if (!exportedWidget) { + return; + } + const blob = new Blob([exportedWidget], { + type: 'text/json', + }); + const [day, month, year] = new Date() + .toLocaleDateString('fr-FR') + .split('/'); + const fileName = `${year}${month}${day}_octi_widget_${widget.type}.json`; + fileDownload(blob, fileName); + }); + }; + + const handleAddWidget = (widgetConfig: DashboardManifest['widgets'][number]) => { + saveManifest({ + ...manifest, + widgets: { + ...manifest.widgets, + [widgetConfig.id]: { + ...widgetConfig, + layout: { + i: widgetConfig.id, + x: 0, + y: getNextRow(), + w: 4, + h: 2, + moved: false, + static: false, + }, + }, + }, + }); + }; + + const handleUpdateWidget = (widgetManifest: DashboardManifest['widgets'][number]) => { + const newManifest = { + ...manifest, + widgets: { ...manifest.widgets, [widgetManifest.id]: widgetManifest }, + }; + saveManifest(newManifest); + }; + + const handleDeleteWidget = (widgetId: string) => { + setDeleting(true); + const newWidgets = { ...manifest.widgets }; + delete newWidgets[widgetId]; + saveManifest({ + ...manifest, + widgets: newWidgets, + }); + }; + + const handleDuplicateWidget = (widgetToDuplicate: DashboardManifest['widgets'][number]) => { + handleAddWidget({ + ...widgetToDuplicate, + id: uuid(), + }); + }; + + const handleLayoutChange = (layouts: ReadonlyArray) => { + if (deleting) return; + + const newLayouts = layouts.reduce((res, layout) => { + res[layout.i] = layout; + return res; + }, {} as Record); + + if (R.equals(newLayouts, widgetsLayouts)) return; // ⛔ prevent loop + + setWidgetsLayouts(newLayouts); + saveManifest(manifest, { layouts: newLayouts, noRefresh: true }); + }; + + const config = manifest.config; + + return { + handleAddWidget, + handleDateChange, + handleUpdateWidget, + handleDeleteWidget, + handleDuplicateWidget, + handleLayoutChange, + handleImportWidget, + handleExportWidget, + idToResize, + handleResize, + config, + widgetsArray, + widgetsLayouts, + }; +} + +export default useDashboard; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/WorkspaceShareList.tsx b/opencti-platform/opencti-front/src/private/components/workspaces/WorkspaceShareList.tsx index 9f0ddaa9f7ec..3cfc75c5158a 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/WorkspaceShareList.tsx +++ b/opencti-platform/opencti-front/src/private/components/workspaces/WorkspaceShareList.tsx @@ -13,7 +13,7 @@ import type { Theme } from '../../../components/Theme'; import ItemMarkings from '../../../components/ItemMarkings'; import ItemBoolean from '../../../components/ItemBoolean'; import useAuth from '../../../utils/hooks/useAuth'; -import { copyPublicLinkUrl } from '../../../utils/dashboard'; +import { copyPublicLinkUrl } from './workspace-utils'; export const workspaceShareListQuery = graphql` query WorkspaceShareListQuery($filters: FilterGroup) { diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/public_dashboards/PublicDashboardLineActions.tsx b/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/public_dashboards/PublicDashboardLineActions.tsx index d7455150acd4..2432a5f4c885 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/public_dashboards/PublicDashboardLineActions.tsx +++ b/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/public_dashboards/PublicDashboardLineActions.tsx @@ -13,7 +13,7 @@ import { EXPLORE_EXUPDATE_PUBLISH } from '../../../../../utils/hooks/useGranted' import Security from '../../../../../utils/Security'; import { useGetCurrentUserAccessRight } from '../../../../../utils/authorizedMembers'; import { deleteNode } from '../../../../../utils/store'; -import { copyPublicLinkUrl } from '../../../../../utils/dashboard'; +import { copyPublicLinkUrl } from '../../workspace-utils'; interface PublicDashboardLineActionsProps { publicDashboard: PublicDashboards_PublicDashboard$data; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/workspace-utils.ts b/opencti-platform/opencti-front/src/private/components/workspaces/workspace-utils.ts new file mode 100644 index 000000000000..f009bdde1820 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/workspaces/workspace-utils.ts @@ -0,0 +1,9 @@ +import { copyToClipboard } from '../../../utils/utils'; +import { APP_BASE_PATH } from '../../../relay/environment'; + +export const copyPublicLinkUrl = (t: (text: string) => string, uriKey: string) => { + copyToClipboard( + t, + `${window.location.origin}${APP_BASE_PATH}/public/dashboard/${uriKey.toLowerCase()}`, + ); +}; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx b/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx index 87697fb0bb4b..74d4b2bff661 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx +++ b/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx @@ -11,7 +11,7 @@ import { EXPLORE_EXUPDATE } from 'src/utils/hooks/useGranted'; import ExportButtons from 'src/components/ExportButtons'; import { useGetCurrentUserAccessRight } from 'src/utils/authorizedMembers'; import { truncate } from 'src/utils/String'; -import WorkspaceWidgetConfig from 'src/private/components/workspaces/dashboards/WorkspaceWidgetConfig'; +import DashboardWidgetConfig from 'src/components/dashboard/DashboardWidgetConfig'; import { WorkspaceHeaderToStixReportBundleQuery$data } from '@components/workspaces/workspaceHeader/__generated__/WorkspaceHeaderToStixReportBundleQuery.graphql'; import WorkspaceKebabMenu from '@components/workspaces/WorkspaceKebabMenu'; import WorkspaceHeaderTagManager from '@components/workspaces/workspaceHeader/WorkspaceHeaderTagManager'; @@ -120,7 +120,7 @@ const WorkspaceHeader = ({ needs={[EXPLORE_EXUPDATE]} hasAccess={canEdit} > - diff --git a/opencti-platform/opencti-front/src/public/components/PublicDashboard.tsx b/opencti-platform/opencti-front/src/public/components/PublicDashboard.tsx index 59f850599d96..f946f69c7c64 100644 --- a/opencti-platform/opencti-front/src/public/components/PublicDashboard.tsx +++ b/opencti-platform/opencti-front/src/public/components/PublicDashboard.tsx @@ -11,7 +11,7 @@ import usePublicDashboardWidgets from './dashboard/usePublicDashboardWidgets'; import PublicTopBar from './PublicTopBar'; import PublicDashboardHeader from './dashboard/PublicDashboardHeader'; import { useFormatter } from '../../components/i18n'; -import type { DashboardManifest } from '../../utils/dashboard'; +import type { DashboardManifest } from '../../components/dashboard/dashboard-types'; const publicDashboardQuery = graphql` query PublicDashboardQuery($uri_key: String!) { diff --git a/opencti-platform/opencti-front/src/public/components/dashboard/PublicDashboardHeader.tsx b/opencti-platform/opencti-front/src/public/components/dashboard/PublicDashboardHeader.tsx index 3c55aa2b5a36..554fadb4e39f 100644 --- a/opencti-platform/opencti-front/src/public/components/dashboard/PublicDashboardHeader.tsx +++ b/opencti-platform/opencti-front/src/public/components/dashboard/PublicDashboardHeader.tsx @@ -5,7 +5,7 @@ import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { useFormatter } from '../../../components/i18n'; -import type { DashboardConfig } from '../../../utils/dashboard'; +import type { DashboardConfig } from '../../../components/dashboard/dashboard-types'; import { buildDate } from '../../../utils/Time'; interface PublicDashboardHeaderProps { diff --git a/opencti-platform/opencti-front/src/public/components/dashboard/usePublicDashboardWidgets.tsx b/opencti-platform/opencti-front/src/public/components/dashboard/usePublicDashboardWidgets.tsx index a3ab0b14177f..fe296bdce031 100644 --- a/opencti-platform/opencti-front/src/public/components/dashboard/usePublicDashboardWidgets.tsx +++ b/opencti-platform/opencti-front/src/public/components/dashboard/usePublicDashboardWidgets.tsx @@ -1,6 +1,6 @@ import React from 'react'; import WidgetText from '../../../components/dashboard/WidgetText'; -import type { DashboardConfig } from '../../../utils/dashboard'; +import type { DashboardConfig } from '../../../components/dashboard/dashboard-types'; import { computerRelativeDate, dayStartDate, formatDate } from '../../../utils/Time'; import PublicStixCoreObjectsNumber from './stix_core_objects/PublicStixCoreObjectsNumber'; import PublicStixCoreObjectsList from './stix_core_objects/PublicStixCoreObjectsList'; diff --git a/opencti-platform/opencti-front/src/utils/dashboard.ts b/opencti-platform/opencti-front/src/utils/dashboard.ts deleted file mode 100644 index d1b1ba94b178..000000000000 --- a/opencti-platform/opencti-front/src/utils/dashboard.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { copyToClipboard } from './utils'; -import { APP_BASE_PATH } from '../relay/environment'; -import type { Widget } from './widget/widget'; - -export interface DashboardConfig { - startDate?: string; - endDate?: string; - relativeDate?: string; -} - -export interface DashboardManifest { - config: DashboardConfig; - widgets: Record; -} - -export const copyPublicLinkUrl = (t: (text: string) => string, uriKey: string) => { - copyToClipboard( - t, - `${window.location.origin}${APP_BASE_PATH}/public/dashboard/${uriKey.toLowerCase()}`, - ); -}; diff --git a/opencti-platform/opencti-front/src/utils/filters/filtersUtils.tsx b/opencti-platform/opencti-front/src/utils/filters/filtersUtils.tsx index 1fd3fcdb2c55..23901d0fe20f 100644 --- a/opencti-platform/opencti-front/src/utils/filters/filtersUtils.tsx +++ b/opencti-platform/opencti-front/src/utils/filters/filtersUtils.tsx @@ -11,7 +11,7 @@ import { isEmptyField, uniqueArray } from '../utils'; import { Filter, FilterGroup, FilterValue, handleFilterHelpers } from './filtersHelpers-types'; import { dateFiltersValueForDisplay } from '../Time'; import { RELATIONSHIP_WIDGETS_TYPES } from '../widget/widgetUtils'; -import type { DashboardManifest } from '../dashboard'; +import type { DashboardManifest } from '../../components/dashboard/dashboard-types'; // ---------------------------------------------------------------------------------------------------------------------- diff --git a/opencti-platform/opencti-front/src/utils/widget/widget.d.ts b/opencti-platform/opencti-front/src/utils/widget/widget.d.ts index 72376a75e1b0..b09509121951 100644 --- a/opencti-platform/opencti-front/src/utils/widget/widget.d.ts +++ b/opencti-platform/opencti-front/src/utils/widget/widget.d.ts @@ -47,8 +47,8 @@ interface WidgetLayout { x: number; y: number; i: string; - moved: boolean; - static: boolean; + moved?: boolean; + static?: boolean; } export interface Widget { From 58bd4f83f276b634b20bedaf0fbdfd6ca09b8bcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Thu, 16 Apr 2026 13:40:08 +0200 Subject: [PATCH 07/17] [frontend] Cleanup dashboard margins shenanigans --- .../dashboard/DashboardTimeFilters.tsx | 2 +- .../components/custom_views/CustomView.tsx | 7 +- .../workspaces/dashboards/Dashboard.jsx | 112 +++++++++--------- .../workspaceHeader/WorkspaceHeader.tsx | 2 +- 4 files changed, 59 insertions(+), 64 deletions(-) diff --git a/opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx b/opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx index 2b6a724cc311..a2cf0d46b0e3 100644 --- a/opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx +++ b/opencti-platform/opencti-front/src/components/dashboard/DashboardTimeFilters.tsx @@ -51,7 +51,7 @@ const DashboardTimeFilters: React.FC = ({ { return ( { id="container" ref={containerRef} sx={{ - margin: '0 -20px 0 -20px', - marginTop: noToolbar ? '-20px' : '10px', '& .react-grid-item.react-grid-placeholder': { border: `2px solid ${theme.palette.primary.main}`, borderRadius: 1, }, }} > - {!noToolbar && ( - - - - - )} - true} - onResizeStart={userCanEdit ? (_, { i }) => handleResize(i) : undefined} - onResizeStop={userCanEdit ? handleResize : undefined} - > - {widgetsArray.map((widget) => { - if (!widgetsLayouts[widget.id]) return null; - const popover = userCanEdit && !noToolbar && ( - handleDeleteWidget(widget.id)} + + {!noToolbar && ( + + - ); + + + )} + true} + onResizeStart={userCanEdit ? (_, { i }) => handleResize(i) : undefined} + onResizeStop={userCanEdit ? handleResize : undefined} + > + {widgetsArray.map((widget) => { + if (!widgetsLayouts[widget.id]) return null; + const popover = userCanEdit && !noToolbar && ( + handleDeleteWidget(widget.id)} + /> + ); - return ( -
- {widget.id === idToResize ?
: ( - - )} -
- ); - })} - + return ( +
+ {widget.id === idToResize ?
: ( + + )} +
+ ); + })} + + ); }; diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx b/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx index 74d4b2bff661..c07c25d1f34f 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx +++ b/opencti-platform/opencti-front/src/private/components/workspaces/workspaceHeader/WorkspaceHeader.tsx @@ -87,7 +87,7 @@ const WorkspaceHeader = ({ return ( <> -
+
Date: Sun, 19 Apr 2026 21:15:11 +0200 Subject: [PATCH 08/17] [frontend] Use DashboardContent in CustomView display --- .../components/custom_views/CustomView.tsx | 86 ++++++++++--------- .../CustomViewRedirector.test.tsx | 2 +- .../custom_views/CustomViewRedirector.tsx | 4 +- .../custom_views/RootCustomView.tsx | 49 ----------- .../components/widgets/useDashboard.ts | 33 ------- 5 files changed, 50 insertions(+), 124 deletions(-) delete mode 100644 opencti-platform/opencti-front/src/private/components/custom_views/RootCustomView.tsx delete mode 100644 opencti-platform/opencti-front/src/private/components/widgets/useDashboard.ts diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/CustomView.tsx b/opencti-platform/opencti-front/src/private/components/custom_views/CustomView.tsx index 0bc506f4e140..1fd1cdf97bbe 100644 --- a/opencti-platform/opencti-front/src/private/components/custom_views/CustomView.tsx +++ b/opencti-platform/opencti-front/src/private/components/custom_views/CustomView.tsx @@ -1,47 +1,55 @@ -import ReactGridLayout, { useContainerWidth } from 'react-grid-layout'; -import { Box } from '@mui/material'; -import DashboardViz from '@components/workspaces/dashboards/DashboardViz'; -import useDashboard from '@components/widgets/useDashboard'; +import { Suspense } from 'react'; +import { graphql, PreloadedQuery, usePreloadedQuery } from 'react-relay'; +import { ErrorBoundary } from '@components/Error'; +import Loader from '../../../components/Loader'; +import useQueryLoading from '../../../utils/hooks/useQueryLoading'; +import useDashboard from '../../../components/dashboard/useDashboard'; +import DashboardContent from '../../../components/dashboard/DashboardContent'; +import { CustomView_Query } from './__generated__/CustomView_Query.graphql'; + +const customViewQuery = graphql` + query CustomView_Query($id: ID!) { + customViewDisplay(id: $id) { + id + manifest + } + } +`; + +interface CustomViewComponentProps { + queryRef: PreloadedQuery; +} + +const CustomViewComponent = ({ queryRef }: CustomViewComponentProps) => { + const { customViewDisplay: customView } = usePreloadedQuery(customViewQuery, queryRef); + if (!customView) { + throw new Error('Unable to load custom view'); + } + if (!customView?.manifest) { + // Admin hasn't save the dashboard once yet + return null; + } + + const helpers = useDashboard({ entity: customView }); + return ; +}; interface CustomViewProps { - manifest: string; + customViewId: string; } -/** - * Displays a custom view from its serialized content - */ -const CustomView = ({ manifest }: CustomViewProps) => { - const { width, containerRef } = useContainerWidth(); - const { config, widgetsArray, widgetsLayouts } = useDashboard(manifest); +export const CustomView = ({ customViewId }: CustomViewProps) => { + const queryRef = useQueryLoading( + customViewQuery, + { id: customViewId }, + ); + return ( - - - {widgetsArray.map((widget) => ( -
- -
- ))} -
-
+ + }> + {queryRef && } + + ); }; diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.test.tsx b/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.test.tsx index ee605fa52dac..082431b6453d 100644 --- a/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.test.tsx +++ b/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.test.tsx @@ -6,7 +6,7 @@ import { Route, Routes } from 'react-router-dom'; const CUSTOM_VIEW_MOCK_CONTENT = 'A great custom view page'; -vi.mock('./RootCustomView', () => ({ +vi.mock('./CustomView', () => ({ default: () => {CUSTOM_VIEW_MOCK_CONTENT}, __esModule: true, })); diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx b/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx index 3e96e2944d1b..6708f84e51f4 100644 --- a/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx +++ b/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx @@ -1,5 +1,5 @@ import { ReactNode, useMemo } from 'react'; -import RootCustomView from './RootCustomView'; +import CustomView from './CustomView'; import { useCustomViews } from './useCustomViews'; import type { CustomViewsInfo } from './CustomViews-types'; import SlugRedirectHandler, { type SlugRedirectHandlerPageInfo } from '../../../components/SlugRedirectHandler'; @@ -10,7 +10,7 @@ interface CustomViewRedirectorProps { } const renderMatch = (info: SlugRedirectHandlerPageInfo) => - ; + ; const CustomViewRedirector = ({ entityType, Fallback }: CustomViewRedirectorProps) => { const { customViews } = useCustomViews(entityType); diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/RootCustomView.tsx b/opencti-platform/opencti-front/src/private/components/custom_views/RootCustomView.tsx deleted file mode 100644 index 954175f1fb06..000000000000 --- a/opencti-platform/opencti-front/src/private/components/custom_views/RootCustomView.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Suspense } from 'react'; -import { graphql, PreloadedQuery, usePreloadedQuery } from 'react-relay'; -import { ErrorBoundary } from '@components/Error'; -import Loader from '../../../components/Loader'; -import useQueryLoading from '../../../utils/hooks/useQueryLoading'; -import { RootCustomViewQuery } from './__generated__/RootCustomViewQuery.graphql'; -import CustomView from './CustomView'; - -const customViewQuery = graphql` - query RootCustomViewQuery($id: ID!) { - customViewDisplay(id: $id) { - manifest - } - } -`; - -interface RootCustomViewComponentProps { - queryRef: PreloadedQuery; -} - -const RootCustomViewComponent = ({ queryRef }: RootCustomViewComponentProps) => { - const { customViewDisplay } = usePreloadedQuery(customViewQuery, queryRef); - if (!customViewDisplay?.manifest) { - throw new Error('Unable to load custom view'); - } - - return ; -}; - -interface RootCustomViewProps { - customViewId: string; -} - -export const RootCustomView = ({ customViewId }: RootCustomViewProps) => { - const queryRef = useQueryLoading( - customViewQuery, - { id: customViewId }, - ); - - return ( - - }> - {queryRef && } - - - ); -}; - -export default RootCustomView; diff --git a/opencti-platform/opencti-front/src/private/components/widgets/useDashboard.ts b/opencti-platform/opencti-front/src/private/components/widgets/useDashboard.ts deleted file mode 100644 index d25ea25f9051..000000000000 --- a/opencti-platform/opencti-front/src/private/components/widgets/useDashboard.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useMemo } from 'react'; -import { fromB64 } from '../../../utils/String'; -import { deserializeDashboardManifestForFrontend } from '../../../utils/filters/filtersUtils'; -import type { WidgetLayout } from '../../../utils/widget/widget'; - -/** - * Display widgets in a layout. - * Not to be used when editing the layout or widgets. - * - * @param serializedManifest - The serialized version of the widgets+layout content. - */ -function useDashboard(serializedManifest: string) { - const manifest = useMemo(() => - deserializeDashboardManifestForFrontend(fromB64(serializedManifest)), - [serializedManifest]); - - const widgetsArray = Object.values(manifest.widgets).filter(({ layout }) => layout); - - const widgetsLayouts = widgetsArray.reduce((res, widget) => { - res[widget.id] = widget.layout!; - return res; - }, {} as Record); - - const config = manifest.config; - - return { - widgetsArray, - widgetsLayouts, - config, - }; -} - -export default useDashboard; From b1a51480d7a59836f7eb7b238bf89662e1012ba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Sun, 19 Apr 2026 21:43:19 +0200 Subject: [PATCH 09/17] [frontend] Apply commons to custom dashboards --- .../workspaces/dashboards/Dashboard.jsx | 319 ++++-------------- 1 file changed, 58 insertions(+), 261 deletions(-) diff --git a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/Dashboard.jsx b/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/Dashboard.jsx index 45661de87616..d59f85d5c804 100644 --- a/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/Dashboard.jsx +++ b/opencti-platform/opencti-front/src/private/components/workspaces/dashboards/Dashboard.jsx @@ -1,20 +1,21 @@ -import { useEffect, useMemo, useState } from 'react'; -import * as R from 'ramda'; import { graphql, useFragment } from 'react-relay'; -import ReactGridLayout, { useContainerWidth } from 'react-grid-layout'; -import { v4 as uuid } from 'uuid'; -import { Stack, Box } from '@mui/material'; -import { useTheme } from '@mui/styles'; -import DashboardTimeFilters from './DashboardTimeFilters'; +import Stack from '@mui/material/Stack'; +import DashboardTimeFilters from '../../../../components/dashboard/DashboardTimeFilters'; import WorkspaceHeader from '../workspaceHeader/WorkspaceHeader'; -import { commitMutation, handleError } from '../../../../relay/environment'; +import { commitMutation, handleError, fetchQuery } from '../../../../relay/environment'; import { workspaceMutationFieldPatch } from '../WorkspaceEditionOverview'; import useGranted, { EXPLORE_EXUPDATE } from '../../../../utils/hooks/useGranted'; -import WorkspaceWidgetPopover from './WorkspaceWidgetPopover'; -import { fromB64, toB64 } from '../../../../utils/String'; -import { deserializeDashboardManifestForFrontend, serializeDashboardManifestForBackend } from '../../../../utils/filters/filtersUtils'; import useApiMutation from '../../../../utils/hooks/useApiMutation'; -import DashboardViz from './DashboardViz'; +import DashboardContent from '../../../../components/dashboard/DashboardContent'; +import useDashboard from '../../../../components/dashboard/useDashboard'; + +const dashboardExportWidgetQuery = graphql` + query DashboardWidgetExportQuery($id: String!, $widgetId: ID!) { + workspace(id: $id) { + toWidgetExport(widgetId: $widgetId) + } + } +`; const dashboardLayoutMutation = graphql` mutation DashboardLayoutMutation($id: ID!, $input: [EditInput!]!) { @@ -54,142 +55,40 @@ const dashboardFragment = graphql` } `; -const DashboardComponent = ({ data, noToolbar = false }) => { - const theme = useTheme(); - const [commitWidgetImportMutation] = useApiMutation(dashboardImportWidgetMutation); +const onExportWidget = async (id, widget) => { + const data = await fetchQuery(dashboardExportWidgetQuery, { id, widgetId: widget.id }) + .toPromise(); + return data.workspace?.toWidgetExport; +}; +const DashboardComponent = ({ data, noToolbar = false }) => { const workspace = useFragment(dashboardFragment, data); - const { width, containerRef } = useContainerWidth(); - - const [deleting, setDeleting] = useState(false); - const [idToResize, setIdToResize] = useState(); - const handleResize = (updatedWidget) => setIdToResize(updatedWidget); + const [commitWidgetImportMutation] = useApiMutation(dashboardImportWidgetMutation); const userHasEditAccess = workspace.currentUserAccessRight === 'admin' || workspace.currentUserAccessRight === 'edit'; const userHasUpdateCapa = useGranted([EXPLORE_EXUPDATE]); const userCanEdit = userHasEditAccess && userHasUpdateCapa; - useEffect(() => { - const timeout = setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 1200); - return () => { - clearTimeout(timeout); - }; - }, []); - - // Map of widget layouts, refreshed when workspace is updated (thanks to useMemo below). - // We use a local map of layouts to avoid a lot of computation when only changing position - // or dimension of widgets. - const [widgetsLayouts, setWidgetsLayouts] = useState({}); - - // Deserialized manifest, refreshed when workspace is updated. - const manifest = useMemo(() => { - return workspace.manifest && workspace.manifest.length > 0 - ? deserializeDashboardManifestForFrontend(fromB64(workspace.manifest)) - : { widgets: {}, config: {} }; - }, [workspace.manifest]); - - // Array of all widgets, refreshed when workspace is updated. - const widgetsArray = useMemo(() => { - return Object.values(manifest.widgets); - }, [manifest]); - - useEffect(() => { - setWidgetsLayouts( - widgetsArray.reduce((res, widget) => { - res[widget.id] = widget.layout; - return res; - }, {}), - ); - }, [widgetsArray]); - - /** - * Merge a manifest with some layouts and transform it in base64. - * - * @param newManifest Manifest to merge with local changes and stringify. - * @param layouts Local layout changes. - * @returns {string} Manifest in B64. - */ - const prepareManifest = (newManifest, layouts) => { - // Need to sync manifest with local layouts before sending for update. - // A desync occurs when resizing or moving a widget because in those cases - // we skip a complete reload to avoid performance issue. - const syncWidgets = Object.values(newManifest.widgets).reduce((res, widget) => { - const localLayout = layouts[widget.id]; - res[widget.id] = { - ...widget, - layout: localLayout || widget.layout, - }; - return res; - }, {}); - const manifestToSave = { - ...newManifest, - widgets: syncWidgets, - }; - - const strManifest = serializeDashboardManifestForBackend(manifestToSave); - return toB64(strManifest); - }; - - const saveManifest = (newManifest, opts = { layouts: widgetsLayouts, noRefresh: false }) => { - const { layouts, noRefresh } = opts; - const newManifestEncoded = prepareManifest(newManifest, layouts); - // Sometimes (in case of layout adjustment) we do not want to re-fetch - // all the manifest because widgets data is still the same, and it's costly - // in performance. + const onSave = (id, newManifestEncoded, noRefresh, onCompleted) => { const mutation = noRefresh ? dashboardLayoutMutation : workspaceMutationFieldPatch; - if (workspace.manifest !== newManifestEncoded) { - commitMutation({ - mutation, - variables: { - id: workspace.id, - input: { - key: 'manifest', - value: newManifestEncoded, - }, - }, - onCompleted: () => { - setDeleting(false); + commitMutation({ + mutation, + variables: { + id, + input: { + key: 'manifest', + value: newManifestEncoded, }, - }); - } - }; - - const handleDateChange = (type, value) => { - let newManifest = R.assoc( - 'config', - R.assoc(type, value === 'none' ? null : value, manifest.config), - manifest, - ); - if (type === 'relativeDate' && value !== 'none') { - newManifest = R.assoc( - 'config', - R.assoc('startDate', null, newManifest.config), - newManifest, - ); - newManifest = R.assoc( - 'config', - R.assoc('endDate', null, newManifest.config), - newManifest, - ); - } - saveManifest(newManifest); - }; - - const getNextRow = () => { - return widgetsArray.reduce((max, { layout }) => { - const widgetEndRow = layout.y + layout.h; - return widgetEndRow > max ? widgetEndRow : max; - }, 0); + }, + onCompleted, + }); }; - const importWidget = (widgetConfig) => { - const manifestEncoded = prepareManifest(manifest, widgetsLayouts); + const onImportWidget = (id, widgetConfig, manifestEncoded) => { commitWidgetImportMutation({ variables: { - id: workspace.id, + id, input: { importType: 'widget', file: widgetConfig, @@ -202,134 +101,32 @@ const DashboardComponent = ({ data, noToolbar = false }) => { }); }; - const handleAddWidget = (widgetConfig) => { - saveManifest({ - ...manifest, - widgets: { - ...manifest.widgets, - [widgetConfig.id]: { - ...widgetConfig, - layout: { - i: widgetConfig.id, - x: 0, - y: getNextRow(), - w: 4, - h: 2, - }, - }, - }, - }); - }; - - const handleUpdateWidget = (widgetManifest) => { - const newManifest = { - ...manifest, - widgets: { ...manifest.widgets, [widgetManifest.id]: widgetManifest }, - }; - saveManifest(newManifest); - }; - - const handleDeleteWidget = (widgetId) => { - setDeleting(true); - const newWidgets = { ...manifest.widgets }; - delete newWidgets[widgetId]; - saveManifest({ - ...manifest, - widgets: newWidgets, - }); - }; - - const handleDuplicateWidget = (widgetToDuplicate) => { - handleAddWidget({ - ...widgetToDuplicate, - id: uuid(), - }); - }; - - const onLayoutChange = (layouts) => { - if (deleting) return; - - const newLayouts = layouts.reduce((res, layout) => { - res[layout.i] = layout; - return res; - }, {}); - - if (R.equals(newLayouts, widgetsLayouts)) return; // ⛔ prevent loop - - setWidgetsLayouts(newLayouts); - saveManifest(manifest, { layouts: newLayouts, noRefresh: true }); - }; - + const helpers = useDashboard({ entity: workspace, onSave, onImportWidget, onExportWidget }); + const { handleAddWidget, handleImportWidget, handleDateChange, config } = helpers; return ( - - - {!noToolbar && ( - - - - - )} - true} - onResizeStart={userCanEdit ? (_, { i }) => handleResize(i) : undefined} - onResizeStop={userCanEdit ? handleResize : undefined} - > - {widgetsArray.map((widget) => { - if (!widgetsLayouts[widget.id]) return null; - const popover = userCanEdit && !noToolbar && ( - handleDeleteWidget(widget.id)} - /> - ); - - return ( -
- {widget.id === idToResize ?
: ( - - )} -
- ); - })} - - - + + {!noToolbar && ( + + + + + ) + } + + ); }; From 0af69849ad47b81ce23b07a77da9ec82dad48bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Sun, 19 Apr 2026 02:21:28 +0200 Subject: [PATCH 10/17] [backend] Edit custom view --- .../src/schema/relay.schema.graphql | 4 + .../opencti-graphql/src/config/conf.js | 5 + .../opencti-graphql/src/generated/graphql.ts | 30 ++++++ .../modules/customView/customView-domain.ts | 96 ++++++++++++++++++- .../modules/customView/customView-resolver.ts | 24 ++++- .../src/modules/customView/customView.graphql | 4 + .../src/modules/workspace/workspace-domain.ts | 57 +++++++---- 7 files changed, 200 insertions(+), 20 deletions(-) diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index 3330be8f7eb2..22c0d5615d86 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -9817,6 +9817,7 @@ type Query { customViewsDisplayContext: [CustomViewsDisplayContext!] customViewDisplay(id: ID!): CustomView customViewsSettings(entityType: String!): CustomViewsSettings! + customView(id: ID!): CustomView taxiiCollection(id: String!): TaxiiCollection taxiiCollections(first: Int, after: ID, orderBy: TaxiiCollectionOrdering, orderMode: OrderingMode, search: String, filters: FilterGroup): TaxiiCollectionConnection feed(id: String!): Feed @@ -10760,6 +10761,8 @@ type Mutation { ldapProviderEdit(id: ID!, input: LdapInput!): AuthenticationProvider ldapProviderDelete(id: ID!): ID customViewAdd(input: CustomViewAddInput!): CustomView! + customViewEdit(id: ID!, input: [EditInput!]!): CustomView + customViewWidgetConfigurationImport(id: ID!, input: ImportConfigurationInput!): CustomView taxiiCollectionAdd(input: TaxiiCollectionAddInput!): TaxiiCollection taxiiCollectionEdit(id: ID!): TaxiiCollectionEditMutations feedAdd(input: FeedAddInput!): Feed @@ -16192,6 +16195,7 @@ type CustomView implements InternalObject & BasicObject { manifest: String path: String! target_entity_type: String! + toWidgetExport(widgetId: ID!): String! } type CustomViewsDisplayContext { diff --git a/opencti-platform/opencti-graphql/src/config/conf.js b/opencti-platform/opencti-graphql/src/config/conf.js index 2aa7bdb7594a..c34100115337 100644 --- a/opencti-platform/opencti-graphql/src/config/conf.js +++ b/opencti-platform/opencti-graphql/src/config/conf.js @@ -45,6 +45,7 @@ import { ENTITY_TYPE_FINTEL_DESIGN } from '../modules/fintelDesign/fintelDesign- import { ENTITY_TYPE_EMAIL_TEMPLATE } from '../modules/emailTemplate/emailTemplate-types'; import { ENTITY_TYPE_AUTHENTICATION_PROVIDER } from '../modules/authenticationProvider/authenticationProvider-types'; import { ENTITY_TYPE_SECURITY_COVERAGE } from '../modules/securityCoverage/securityCoverage-types'; +import { ENTITY_TYPE_CUSTOM_VIEW } from '../modules/customView/customView-types'; // https://golang.org/src/crypto/x509/root_linux.go const LINUX_CERTFILES = [ @@ -792,6 +793,10 @@ export const BUS_TOPICS = { EDIT_TOPIC: `${TOPIC_PREFIX}ENTITY_TYPE_AUTHENTICATION_PROVIDER_EDIT_TOPIC`, DELETE_TOPIC: `${TOPIC_PREFIX}ENTITY_TYPE_AUTHENTICATION_PROVIDER_DELETE_TOPIC`, }, + [ENTITY_TYPE_CUSTOM_VIEW]: { + EDIT_TOPIC: `${TOPIC_PREFIX}CUSTOM_VIEW_EDIT_TOPIC`, + ADDED_TOPIC: `${TOPIC_PREFIX}CUSTOM_VIEW_ADDED_TOPIC`, + }, }; export const getBusTopicForEntityType = (entityType) => { diff --git a/opencti-platform/opencti-graphql/src/generated/graphql.ts b/opencti-platform/opencti-graphql/src/generated/graphql.ts index ba839d64170d..ad8562679609 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -6446,9 +6446,15 @@ export type CustomView = BasicObject & InternalObject & { slug: Scalars['String']['output']; standard_id: Scalars['String']['output']; target_entity_type: Scalars['String']['output']; + toWidgetExport: Scalars['String']['output']; updated_at: Scalars['DateTime']['output']; }; + +export type CustomViewToWidgetExportArgs = { + widgetId: Scalars['ID']['input']; +}; + export type CustomViewAddInput = { description?: InputMaybe; manifest?: InputMaybe; @@ -16919,6 +16925,8 @@ export type Mutation = { csvMapperFieldPatch?: Maybe; csvMapperTest?: Maybe; customViewAdd: CustomView; + customViewEdit?: Maybe; + customViewWidgetConfigurationImport?: Maybe; dataComponentAdd?: Maybe; dataComponentContextClean?: Maybe; dataComponentContextPatch?: Maybe; @@ -17738,6 +17746,18 @@ export type MutationCustomViewAddArgs = { }; +export type MutationCustomViewEditArgs = { + id: Scalars['ID']['input']; + input: Array; +}; + + +export type MutationCustomViewWidgetConfigurationImportArgs = { + id: Scalars['ID']['input']; + input: ImportConfigurationInput; +}; + + export type MutationDataComponentAddArgs = { input: DataComponentAddInput; }; @@ -24096,6 +24116,7 @@ export type Query = { /** @deprecated [>=6.4 & <6.7]. Use `csvMapperTest mutation`. */ csvMapperTest?: Maybe; csvMappers?: Maybe; + customView?: Maybe; customViewDisplay?: Maybe; customViewsDisplayContext?: Maybe>; customViewsSettings: CustomViewsSettings; @@ -24887,6 +24908,11 @@ export type QueryCsvMappersArgs = { }; +export type QueryCustomViewArgs = { + id: Scalars['ID']['input']; +}; + + export type QueryCustomViewDisplayArgs = { id: Scalars['ID']['input']; }; @@ -42926,6 +42952,7 @@ export type CustomViewResolvers; standard_id?: Resolver; target_entity_type?: Resolver; + toWidgetExport?: Resolver>; updated_at?: Resolver; __isTypeOf?: IsTypeOfResolverFn; }>; @@ -46599,6 +46626,8 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; csvMapperTest?: Resolver, ParentType, ContextType, RequireFields>; customViewAdd?: Resolver>; + customViewEdit?: Resolver, ParentType, ContextType, RequireFields>; + customViewWidgetConfigurationImport?: Resolver, ParentType, ContextType, RequireFields>; dataComponentAdd?: Resolver, ParentType, ContextType, RequireFields>; dataComponentContextClean?: Resolver, ParentType, ContextType, RequireFields>; dataComponentContextPatch?: Resolver, ParentType, ContextType, RequireFields>; @@ -48401,6 +48430,7 @@ export type QueryResolvers, ParentType, ContextType>; csvMapperTest?: Resolver, ParentType, ContextType, RequireFields>; csvMappers?: Resolver, ParentType, ContextType, Partial>; + customView?: Resolver, ParentType, ContextType, RequireFields>; customViewDisplay?: Resolver, ParentType, ContextType, RequireFields>; customViewsDisplayContext?: Resolver>, ParentType, ContextType>; customViewsSettings?: Resolver>; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts index b808211be2c0..e621e18a426c 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts @@ -1,8 +1,8 @@ import slugify from 'slug'; import { fullEntitiesList, storeLoadById } from '../../database/middleware-loader'; import type { AuthContext, AuthUser } from '../../types/user'; -import { ENTITY_TYPE_CUSTOM_VIEW, type BasicStoreEntityCustomView } from './customView-types'; -import { FilterMode, type CustomViewAddInput } from '../../generated/graphql'; +import { ENTITY_TYPE_CUSTOM_VIEW, type BasicStoreEntityCustomView, type StoreEntityCustomView } from './customView-types'; +import { FilterMode, type CustomViewAddInput, type EditInput, type ImportWidgetInput } from '../../generated/graphql'; import { ENTITY_TYPE_CONTAINER_NOTE, ENTITY_TYPE_CONTAINER_OBSERVED_DATA, @@ -17,8 +17,12 @@ import { ENTITY_TYPE_CONTAINER_FEEDBACK } from '../case/feedback/feedback-types' import { ABSTRACT_STIX_CORE_RELATIONSHIP, ABSTRACT_STIX_CYBER_OBSERVABLE, ABSTRACT_STIX_DOMAIN_OBJECT } from '../../schema/general'; import { schemaTypesDefinition } from '../../schema/schema-types'; import { ENTITY_HASHED_OBSERVABLE_ARTIFACT } from '../../schema/stixCyberObservable'; -import { createEntity } from '../../database/middleware'; +import { createEntity, updateAttribute } from '../../database/middleware'; import { now } from '../../utils/format'; +import { publishUserAction } from '../../listener/UserActionListener'; +import { notify } from '../../database/redis'; +import { BUS_TOPICS } from '../../config/conf'; +import { exportWidget, processImportWidgetConfiguration, sanitizeElementForPublishAction } from '../workspace/workspace-domain'; /** * Exclusion list: entity types not capable of @@ -98,6 +102,15 @@ export const getCustomViewsDisplayContext = async (context: AuthContext, user: A // Settings Use Cases (admin users) +export const getCustomViewById = async (context: AuthContext, user: AuthUser, customViewId: string) => { + return storeLoadById( + context, + user, + customViewId, + ENTITY_TYPE_CUSTOM_VIEW, + ); +}; + export const getCustomViewsSettings = async ( context: AuthContext, user: AuthUser, @@ -150,3 +163,80 @@ export const addCustomView = async ( ); return entity; }; + +export const editCustomView = async ( + context: AuthContext, + user: AuthUser, + customViewId: string, + input: EditInput[], +) => { + const nameInput = input.find((i) => i.key === 'name'); + const { element } = await updateAttribute( + context, + user, + customViewId, + ENTITY_TYPE_CUSTOM_VIEW, + [ + ...input, + ...(nameInput ? [{ + key: 'slug', + value: [slugify(nameInput.value[0])], + }] : []), + ], + ); + await publishUserAction({ + user, + event_type: 'mutation', + event_scope: 'update', + event_access: 'administration', + message: `updates \`${input.map((i) => i.key).join(', ')}\` for custom view ${element.name}`, + context_data: { id: element.id, entity_type: ENTITY_TYPE_CUSTOM_VIEW, input }, + }); + + await notify(BUS_TOPICS[ENTITY_TYPE_CUSTOM_VIEW].EDIT_TOPIC, element, user); + return element; +}; + +export const customViewImportWidgetConfiguration = async ( + context: AuthContext, + user: AuthUser, + customViewId: string, + input: ImportWidgetInput, +) => { + const { updatedManifest, importedWidgetId } = await processImportWidgetConfiguration( + context, + user, + input, + ); + const { element } = await updateAttribute( + context, + user, + customViewId, + ENTITY_TYPE_CUSTOM_VIEW, + [{ key: 'manifest', value: [updatedManifest] }], + ); + + await publishUserAction({ + user, + event_type: 'mutation', + event_scope: 'create', + event_access: 'extended', + message: `import widget (id : ${importedWidgetId}) in custom view (id : ${customViewId})`, + context_data: { + id: customViewId, + entity_type: ENTITY_TYPE_CUSTOM_VIEW, + input: sanitizeElementForPublishAction(element), + }, + }); + await notify(BUS_TOPICS[ENTITY_TYPE_CUSTOM_VIEW].EDIT_TOPIC, element, user); + return element; +}; + +export const exportCustomViewWidget = ( + auth: AuthContext, + user: AuthUser, + customView: BasicStoreEntityCustomView, + widgetId: string, +) => { + return exportWidget(auth, user, customView, widgetId); +}; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts index 268dfb6b59b3..333c69594e36 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts @@ -1,5 +1,15 @@ import type { Resolvers } from '../../generated/graphql'; -import { addCustomView, getCustomViewsSettings, getCustomViewByIdForDisplay, getCustomViewsDisplayContext, computeCustomViewPath } from './customView-domain'; +import { + addCustomView, + editCustomView, + getCustomViewsSettings, + getCustomViewByIdForDisplay, + getCustomViewsDisplayContext, + computeCustomViewPath, + getCustomViewById, + customViewImportWidgetConfiguration, + exportCustomViewWidget, +} from './customView-domain'; const customViewResolver: Resolvers = { Query: { @@ -12,16 +22,28 @@ const customViewResolver: Resolvers = { customViewsSettings: (_parent, { entityType }, context) => { return getCustomViewsSettings(context, context.user, entityType); }, + customView: (_parent, { id }, context) => { + return getCustomViewById(context, context.user, id); + }, }, CustomView: { path: (customView) => { return computeCustomViewPath(customView); }, + toWidgetExport: (customView, { widgetId }, context) => { + return exportCustomViewWidget(context, context.user, customView, widgetId); + }, }, Mutation: { customViewAdd: (_, { input }, context) => { return addCustomView(context, context.user, input); }, + customViewEdit: (_, { id, input }, context) => { + return editCustomView(context, context.user, id, input); + }, + customViewWidgetConfigurationImport: (_, { id, input }, context) => { + return customViewImportWidgetConfiguration(context, context.user, id, input); + }, }, }; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql index ea1cf0eb461b..d04658de2081 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql @@ -13,6 +13,7 @@ type CustomView implements InternalObject & BasicObject { manifest: String path: String! # Derived field, not stored in DB target_entity_type: String! + toWidgetExport(widgetId: ID!): String! @auth(for: [SETTINGS_SETCUSTOMIZATION]) @ff(flags: ["CUSTOM_VIEW"]) } type CustomViewsDisplayContext { @@ -50,6 +51,7 @@ type Query { customViewsSettings( entityType: String! ): CustomViewsSettings! @auth(for: [SETTINGS_SETCUSTOMIZATION]) @ff(flags: ["CUSTOM_VIEW"]) + customView(id: ID!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) @ff(flags: ["CUSTOM_VIEW"]) } # Mutations @@ -58,5 +60,7 @@ type Mutation { # Add a new custom view customViewAdd(input: CustomViewAddInput!): CustomView! @auth(for: [SETTINGS_SETCUSTOMIZATION]) + customViewEdit(id: ID!, input: [EditInput!]!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) + customViewWidgetConfigurationImport(id: ID!, input: ImportConfigurationInput!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) } diff --git a/opencti-platform/opencti-graphql/src/modules/workspace/workspace-domain.ts b/opencti-platform/opencti-graphql/src/modules/workspace/workspace-domain.ts index d27505b7418a..9ecf680f4396 100644 --- a/opencti-platform/opencti-graphql/src/modules/workspace/workspace-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/workspace/workspace-domain.ts @@ -37,7 +37,7 @@ import { createInternalObject, editInternalObject } from '../../domain/internalO export const PLATFORM_DASHBOARD = 'cf093b57-713f-404b-a210-a1c5c8cb3791'; -export const sanitizeElementForPublishAction = (element: BasicStoreEntityWorkspace) => { +export const sanitizeElementForPublishAction = (element: T) => { // Because manifest can be huge we remove this data from activity logs. return { ...element, manifest: undefined }; }; @@ -358,19 +358,7 @@ export const generateWidgetExportConfiguration = async (context: AuthContext, us if (workspace.type !== 'dashboard') { throw FunctionalError('WORKSPACE_EXPORT_INCOMPATIBLE_TYPE', { type: workspace.type }); } - const parsedManifest = fromB64(workspace.manifest ?? '{}'); - if (parsedManifest && isNotEmptyField(parsedManifest.widgets) && parsedManifest.widgets[widgetId]) { - const widgetDefinition = parsedManifest.widgets[widgetId]; - delete widgetDefinition.id; // Remove current widget id - await convertWidgetsIds(context, user, [widgetDefinition], 'internal'); - const exportConfigration = { - openCTI_version: pjson.version, - type: 'widget', - configuration: toB64(widgetDefinition) as string, - }; - return JSON.stringify(exportConfigration); - } - throw FunctionalError('WIDGET_EXPORT_NOT_FOUND', { workspace: workspace.id, widget: widgetId }); + return exportWidget(context, user, workspace, widgetId); }; export const workspaceImportConfiguration = async (context: AuthContext, user: AuthUser, file: Promise) => { @@ -425,10 +413,9 @@ interface WidgetConfigImportData extends ConfigImportData { configuration?: string; // widget definition in base64. } -export const workspaceImportWidgetConfiguration = async ( +export const processImportWidgetConfiguration = async ( context: AuthContext, user: AuthUser, - workspaceId: string, input: ImportWidgetInput, ) => { const parsedData = await extractContentFrom(input.file); @@ -464,6 +451,44 @@ export const workspaceImportWidgetConfiguration = async ( }, }; const updatedManifest = toB64(updatedObjects); + return { + updatedManifest, + importedWidgetId, + }; +}; + +interface DashboardLike { + id: string; + manifest?: string | undefined | null; +} + +export const exportWidget = async (context: AuthContext, user: AuthUser, entity: DashboardLike, widgetId: string) => { + const parsedManifest = fromB64(entity.manifest ?? '{}'); + if (parsedManifest && isNotEmptyField(parsedManifest.widgets) && parsedManifest.widgets[widgetId]) { + const widgetDefinition = parsedManifest.widgets[widgetId]; + delete widgetDefinition.id; // Remove current widget id + await convertWidgetsIds(context, user, [widgetDefinition], 'internal'); + const exportConfigration = { + openCTI_version: pjson.version, + type: 'widget', + configuration: toB64(widgetDefinition) as string, + }; + return JSON.stringify(exportConfigration); + } + throw FunctionalError('WIDGET_EXPORT_NOT_FOUND', { workspace: entity.id, widget: widgetId }); +}; + +export const workspaceImportWidgetConfiguration = async ( + context: AuthContext, + user: AuthUser, + workspaceId: string, + input: ImportWidgetInput, +) => { + const { updatedManifest, importedWidgetId } = await processImportWidgetConfiguration( + context, + user, + input, + ); const { element } = await updateAttribute( context, user, From 5ef6873d70cf9340c6654ea462f623e8d641f11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Mon, 20 Apr 2026 01:20:49 +0200 Subject: [PATCH 11/17] [frontend] Edit a custom view --- .../opencti-front/lang/front/de.json | 1 + .../opencti-front/lang/front/en.json | 1 + .../opencti-front/lang/front/es.json | 1 + .../opencti-front/lang/front/fr.json | 1 + .../opencti-front/lang/front/it.json | 1 + .../opencti-front/lang/front/ja.json | 1 + .../opencti-front/lang/front/ko.json | 1 + .../opencti-front/lang/front/ru.json | 1 + .../opencti-front/lang/front/zh.json | 1 + .../components/settings/sub_types/Root.tsx | 7 +- .../custom_views/CustomViewEdition.tsx | 68 ++++++++++ .../custom_views/CustomViewEditionHeader.tsx | 85 +++++++++++++ .../CustomViewsSettingsDataTable.tsx | 2 +- .../useCustomViewDashboardEdit.ts | 116 ++++++++++++++++++ .../src/utils/widget/widgetExportHandler.tsx | 37 ------ 15 files changed, 285 insertions(+), 39 deletions(-) create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEdition.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewDashboardEdit.ts delete mode 100644 opencti-platform/opencti-front/src/utils/widget/widgetExportHandler.tsx diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index 16bed1e87add..5e7675e7a4b7 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -3246,6 +3246,7 @@ "Popover menu": "Popover-Menü", "Popover of actions": "Popover von Aktionen", "Popover of actions workspace": "Popover des Arbeitsbereichs für Aktionen", + "Popover of custom view actions": "Popover der Aktionen der benutzerdefinierten Ansicht", "Positions": "Positionen", "Positions | Locations": "Positionen | Standorte", "Post": "Beitrag", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 5679eec68208..7bf1cd06d4e6 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -3246,6 +3246,7 @@ "Popover menu": "Popover menu", "Popover of actions": "Popover of actions", "Popover of actions workspace": "Popover of actions workspace", + "Popover of custom view actions": "Popover of custom view actions", "Positions": "Positions", "Positions | Locations": "Positions | Locations", "Post": "Post", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index d19f6fa1db78..b10513e13cb3 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -3246,6 +3246,7 @@ "Popover menu": "Menú desplegable", "Popover of actions": "Popover de acciones", "Popover of actions workspace": "Popover del espacio de trabajo de acciones", + "Popover of custom view actions": "Popover de acciones de vista personalizada", "Positions": "Localizaciones", "Positions | Locations": "Puestos | Ubicaciones", "Post": "Publicar", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index edc824663225..becb4ac09285 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -3246,6 +3246,7 @@ "Popover menu": "Menu contextuel", "Popover of actions": "Popover d'actions", "Popover of actions workspace": "Popover de l'espace de travail des actions", + "Popover of custom view actions": "Popover des actions de la vue personnalisée", "Positions": "Positions", "Positions | Locations": "Postes | Emplacements", "Post": "Poste", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index a5ede42f6bab..ad217277deb8 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -3246,6 +3246,7 @@ "Popover menu": "Menu Popover", "Popover of actions": "Popover di azioni", "Popover of actions workspace": "Popover dell'area di lavoro delle azioni", + "Popover of custom view actions": "Popover delle azioni della vista personalizzata", "Positions": "Posizioni", "Positions | Locations": "Posizioni | Località", "Post": "Messaggio", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 1b6d59f0cc3b..06523ad50a2b 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -3246,6 +3246,7 @@ "Popover menu": "ポップオーバー・メニュー", "Popover of actions": "アクションのポップオーバー", "Popover of actions workspace": "アクション・ワークスペースのポップオーバー", + "Popover of custom view actions": "カスタムビューのポップオーバー", "Positions": "位置", "Positions | Locations": "場所", "Post": "投稿", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index e701b76dd2da..73fdaf0c725f 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -3246,6 +3246,7 @@ "Popover menu": "팝오버 메뉴", "Popover of actions": "작업 팝오버", "Popover of actions workspace": "작업 작업 공간의 팝오버", + "Popover of custom view actions": "사용자 지정 뷰 작업의 팝오버", "Positions": "직책", "Positions | Locations": "포지션 | 위치", "Post": "Post", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index 34ff1156b9cb..c6a56325a74f 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -3246,6 +3246,7 @@ "Popover menu": "Всплывающее меню", "Popover of actions": "Всплывающее меню действий", "Popover of actions workspace": "Всплывающее окно рабочей области действий", + "Popover of custom view actions": "Поповер действий пользовательского представления", "Positions": "Позиции", "Positions | Locations": "Должности | Места", "Post": "Пост", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index 1de340c7800a..a9682deb139a 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -3246,6 +3246,7 @@ "Popover menu": "弹出式菜单", "Popover of actions": "行动弹出窗口", "Popover of actions workspace": "行动工作区的弹出窗口", + "Popover of custom view actions": "自定义视图操作的弹出窗口", "Positions": "位置", "Positions | Locations": "位置|位置", "Post": "发布", diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/Root.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/Root.tsx index 773dc521c636..e42284203f9f 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/Root.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/Root.tsx @@ -9,6 +9,7 @@ import EntitySettingAttributesCard from './entity_setting/EntitySettingAttribute import EntitySettingCustomOverview from './entity_setting/EntitySettingCustomOverview'; import FintelTemplatesManager from './fintel_templates/FintelTemplatesManager'; import GlobalWorkflowSettingsCard from './workflow/GlobalWorkflowSettingsCard'; +import CustomViewEdition from './custom_views/CustomViewEdition'; import CustomViewsSettings from './custom_views/CustomViewsSettings'; import { SUBTYPE_TAB_ATTRIBUTES, @@ -58,13 +59,17 @@ const RootSubType = () => { {isCustomViewFeatureEnabled ? } /> : null} )} /> + } + /> } /> diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEdition.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEdition.tsx new file mode 100644 index 000000000000..d7b7bb40d7d4 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEdition.tsx @@ -0,0 +1,68 @@ +import { Suspense } from 'react'; +import { PreloadedQuery, usePreloadedQuery } from 'react-relay'; +import { useParams } from 'react-router-dom'; +import { Stack } from '@mui/material'; +import ErrorNotFound from '../../../../../components/ErrorNotFound'; +import useQueryLoading from '../../../../../utils/hooks/useQueryLoading'; +import Loader from '../../../../../components/Loader'; +import DashboardTimeFilters from '../../../../../components/dashboard/DashboardTimeFilters'; +import DashboardContent from '../../../../../components/dashboard/DashboardContent'; +import type { useCustomViewDashboardEdit_Query } from './__generated__/useCustomViewDashboardEdit_Query.graphql'; +import CustomViewEditionHeader from './CustomViewEditionHeader'; +import useCustomViewDashboardEdit, { customViewQuery } from './useCustomViewDashboardEdit'; + +interface CustomViewEditionComponentProps { + queryRef: PreloadedQuery; +} + +const CustomViewEditionComponent = ({ queryRef }: CustomViewEditionComponentProps) => { + const { customView } = usePreloadedQuery(customViewQuery, queryRef); + const helpers = useCustomViewDashboardEdit({ customView }); + const { handleAddWidget, handleImportWidget, handleDateChange, config } = helpers; + if (!customView) { + return ; + } + return ( + + + + + + + + ); +}; + +const CustomViewEdition = () => { + const { customViewId } = useParams<{ customViewId?: string }>(); + if (!customViewId) { + return ; + } + + const queryRef = useQueryLoading( + customViewQuery, + { + id: customViewId, + }, + ); + + return ( + }> + {queryRef && } + + ); +}; + +export default CustomViewEdition; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx new file mode 100644 index 000000000000..00090e57c417 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx @@ -0,0 +1,85 @@ +import { useState } from 'react'; +import { graphql, useFragment } from 'react-relay'; +import { Box, Typography } from '@mui/material'; +import Button from '@common/button/Button'; +import { CustomViewEditionHeader_customView$key } from './__generated__/CustomViewEditionHeader_customView.graphql'; +import Breadcrumbs from '../../../../../components/Breadcrumbs'; +import { useFormatter } from '../../../../../components/i18n'; +import CustomViewFormDrawer from './CustomViewFormDrawer'; +import useEntityTranslation from '../../../../../utils/hooks/useEntityTranslation'; +import DashboardWidgetConfig from 'src/components/dashboard/DashboardWidgetConfig'; +import type { DashboardWidget } from '../../../../../components/dashboard/dashboard-types'; +// import ExportButtons from 'src/components/ExportButtons'; + +const headerFragment = graphql` + fragment CustomViewEditionHeader_customView on CustomView { + id + name + description + target_entity_type + } +`; + +interface CustomViewEditionHeaderProps { + data: CustomViewEditionHeader_customView$key; + onImportWidget: (widgetFile: File) => void; + onCreateWidget: (value: DashboardWidget, variableName?: string) => void; +} + +const CustomViewEditionHeader = ({ data, onCreateWidget, onImportWidget }: CustomViewEditionHeaderProps) => { + const { t_i18n } = useFormatter(); + const { translateEntityType } = useEntityTranslation(); + const [isFormOpen, setFormOpen] = useState(false); + + useEntityTranslation(); + const customView = useFragment(headerFragment, data); + const customizationLink = '/dashboard/settings/customization/entity_types'; + const subTypeLink = `${customizationLink}/${customView.target_entity_type}/custom-views`; + const breadcrumb = [ + { label: t_i18n('Settings') }, + { label: t_i18n('Customization') }, + { label: t_i18n('Entity types'), link: customizationLink }, + { label: translateEntityType(customView.target_entity_type), link: subTypeLink }, + { label: t_i18n('Custom Views') }, + { label: customView.name }, + ]; + + return ( + <> + + + + + {customView.name} + + + { + // {}} + // exportToImage={false} + // exportToPdf={false} + // /> + } + + + + + + setFormOpen(false)} + customView={customView} + /> + + ); +}; + +export default CustomViewEditionHeader; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettingsDataTable.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettingsDataTable.tsx index f7993ee41547..92629fe35e4b 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettingsDataTable.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewsSettingsDataTable.tsx @@ -27,7 +27,7 @@ const CustomViewsSettingsDataTable = ({ targetType, }: CustomViewsSettingsDataTableProps) => { const { t_i18n } = useFormatter(); - const getCustomViewLink = (entry: CustomViewsSettingsEntry) => `custom-views/${entry.id}`; + const getCustomViewLink = (entry: CustomViewsSettingsEntry) => `/dashboard/settings/customization/entity_types/${targetType}/custom-views/${entry.id}`; const storageKey = `custom-views-${targetType}`; const { sortedData: sortedCustomViews } = useDataTableLocalSort({ data: customViews, diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewDashboardEdit.ts b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewDashboardEdit.ts new file mode 100644 index 000000000000..436390a0f2bd --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewDashboardEdit.ts @@ -0,0 +1,116 @@ +import { graphql } from 'react-relay'; +import { fetchQuery, handleError } from '../../../../../relay/environment'; +import useApiMutation from '../../../../../utils/hooks/useApiMutation'; +import useDashboard from '../../../../../components/dashboard/useDashboard'; +import { useCustomViewDashboardEdit_Mutation } from './__generated__/useCustomViewDashboardEdit_Mutation.graphql'; +import { useCustomViewDashboardEdit_LayoutMutation } from './__generated__/useCustomViewDashboardEdit_LayoutMutation.graphql'; +import { useCustomViewDashboardEdit_WidgetImportMutation } from './__generated__/useCustomViewDashboardEdit_WidgetImportMutation.graphql'; +import { useCustomViewDashboardEdit_WidgetExportQuery$data } from './__generated__/useCustomViewDashboardEdit_WidgetExportQuery.graphql'; +import { useCustomViewDashboardEdit_Query$data } from './__generated__/useCustomViewDashboardEdit_Query.graphql'; + +export const customViewQuery = graphql` + query useCustomViewDashboardEdit_Query($id: ID!) { + customView(id: $id) { + id + manifest + ...CustomViewEditionHeader_customView + } + } +`; + +const customViewLayoutMutation = graphql` + mutation useCustomViewDashboardEdit_LayoutMutation($id: ID!, $input: [EditInput!]!) { + customViewEdit(id: $id, input: $input) { + id + } + } +`; + +export const customViewMutation = graphql` + mutation useCustomViewDashboardEdit_Mutation( + $id: ID! + $input: [EditInput!]! + ) { + customViewEdit(id: $id, input: $input) { + id + manifest + ...CustomViewEditionHeader_customView + } + } +`; + +const customViewImportWidgetMutation = graphql` + mutation useCustomViewDashboardEdit_WidgetImportMutation( + $id: ID! + $input: ImportConfigurationInput! + ) { + customViewWidgetConfigurationImport(id: $id, input: $input) { + id + manifest + ...CustomViewEditionHeader_customView + } + } +`; + +const customViewExportWidgetQuery = graphql` + query useCustomViewDashboardEdit_WidgetExportQuery($id: ID!, $widgetId: ID!) { + customView(id: $id) { + toWidgetExport(widgetId: $widgetId) + } + } +`; + +const onExportWidget = async (id: string, widget: { id: string; type: string }) => { + const data = await fetchQuery(customViewExportWidgetQuery, { id, widgetId: widget.id }) + .toPromise(); + const result = data as useCustomViewDashboardEdit_WidgetExportQuery$data; + const exportString = result.customView?.toWidgetExport; + if (!exportString) { + throw new Error('Failed to export widget'); + } + return exportString; +}; + +const useCustomViewDashboardEdit = ({ customView }: { + customView: useCustomViewDashboardEdit_Query$data['customView']; +}) => { + const [commitSaveMutation] = useApiMutation(customViewMutation); + const [commitSaveLayoutMutation] = useApiMutation(customViewLayoutMutation); + const [commitImportWidgetMutation] = useApiMutation(customViewImportWidgetMutation); + + const onSave = (id: string, newManifestEncoded: string, noRefresh: boolean, onCompleted: () => void) => { + const commitMutation = noRefresh ? commitSaveLayoutMutation : commitSaveMutation; + commitMutation({ + variables: { + id, + input: [{ + key: 'manifest', + value: [newManifestEncoded], + }], + }, + onCompleted, + onError: () => { + handleError('Failed to save custom view'); + }, + }); + }; + const onImportWidget = (id: string, widgetConfig: unknown, manifestEncoded: string) => { + commitImportWidgetMutation({ + variables: { + id, + input: { + importType: 'widget', + file: widgetConfig, + dashboardManifest: manifestEncoded, + }, + }, + onError: () => { + handleError('Failed to import widget'); + }, + }); + }; + + return useDashboard({ entity: customView, onImportWidget, onSave, onExportWidget }); +}; + +export default useCustomViewDashboardEdit; diff --git a/opencti-platform/opencti-front/src/utils/widget/widgetExportHandler.tsx b/opencti-platform/opencti-front/src/utils/widget/widgetExportHandler.tsx deleted file mode 100644 index 128c379b329b..000000000000 --- a/opencti-platform/opencti-front/src/utils/widget/widgetExportHandler.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import fileDownload from 'js-file-download'; -import { graphql } from 'react-relay'; -import { fetchQuery } from '../../relay/environment'; -import { widgetExportHandlerQuery$data } from './__generated__/widgetExportHandlerQuery.graphql'; - -interface widgetToExport { - id: string; - type: string; -} - -const widgetExportHandlerQuery = graphql` - query widgetExportHandlerQuery($id: String!, $widgetId: ID!) { - workspace(id: $id) { - toWidgetExport(widgetId: $widgetId) - } - } -`; - -const handleWidgetExportJson = (id: string, widget: widgetToExport) => { - fetchQuery(widgetExportHandlerQuery, { id, widgetId: widget.id }) - .toPromise() - .then((data) => { - const result = data as widgetExportHandlerQuery$data; - if (result.workspace) { - const blob = new Blob([result.workspace.toWidgetExport], { - type: 'text/json', - }); - const [day, month, year] = new Date() - .toLocaleDateString('fr-FR') - .split('/'); - const fileName = `${year}${month}${day}_octi_widget_${widget.type}.json`; - fileDownload(blob, fileName); - } - }); -}; - -export default handleWidgetExportJson; From 7167eaa36faca2cf22e25883cb019fe88ebab5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Thu, 16 Apr 2026 13:46:44 +0200 Subject: [PATCH 12/17] [frontend] Update drawer for custom view --- .../opencti-front/lang/front/de.json | 3 + .../opencti-front/lang/front/en.json | 3 + .../opencti-front/lang/front/es.json | 3 + .../opencti-front/lang/front/fr.json | 3 + .../opencti-front/lang/front/it.json | 3 + .../opencti-front/lang/front/ja.json | 3 + .../opencti-front/lang/front/ko.json | 3 + .../opencti-front/lang/front/ru.json | 3 + .../opencti-front/lang/front/zh.json | 3 + .../sub_types/custom_views/CustomViewForm.tsx | 65 +++++++++++-------- .../custom_views/CustomViewFormDrawer.tsx | 36 ++++++++-- .../custom_views/useCustomViewEdit.ts | 43 ++++++++++++ 12 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewEdit.ts diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index 5e7675e7a4b7..306d1340a9dd 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "Benutzerdefinierte Dashboards | Dashboards", "Custom view": "Benutzerdefinierte Ansicht", "Custom view created": "Benutzerdefinierte Ansicht erstellt", + "Custom view updated": "Benutzerdefinierte Ansicht aktualisiert", "Custom Views": "Benutzerdefinierte Ansichten", "Customization": "Anpassungen", "Customize columns": "Spalten anpassen", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "Fehler beim Parsen einer Connector Manager-Vertragsdefinition", "Failed to parse a contract": "Ein Vertrag konnte nicht analysiert werden", "Failed to set draft context.": "Der Entwurfskontext konnte nicht gesetzt werden.", + "Failed to update custom view": "Aktualisierung der benutzerdefinierten Ansicht fehlgeschlagen", "Failed to update theme": "Theme konnte nicht aktualisiert werden", "Failure": "Fehlschlag", "false": "Nein", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "Komponente aktualisieren: {component_name}", "Update connector": "Verbinder aktualisieren", "Update context": "Kontext aktualisieren", + "Update custom view": "Benutzerdefinierte Ansicht aktualisieren", "Update dashboard": "Dashboard aktualisieren", "Update date": "Aktualisierungsdatum", "Update entities": "Entitäten aktualisieren", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 7bf1cd06d4e6..1176d2096d3a 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "Custom dashboards | dashboards", "Custom view": "Custom view", "Custom view created": "Custom view created", + "Custom view updated": "Custom view updated", "Custom Views": "Custom Views", "Customization": "Customization", "Customize columns": "Customize columns", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "Failed to parse a connector manager contract definition", "Failed to parse a contract": "Failed to parse a contract", "Failed to set draft context.": "Failed to set draft context.", + "Failed to update custom view": "Failed to update custom view", "Failed to update theme": "Failed to update theme", "Failure": "Failure", "false": "false", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "Update component: {component_name}", "Update connector": "Update connector", "Update context": "Update context", + "Update custom view": "Update custom view", "Update dashboard": "Update dashboard", "Update date": "Update date", "Update entities": "Update entities", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index b10513e13cb3..39af282ac154 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "Cuadros de mando personalizados | Cuadros de mando", "Custom view": "Vista personalizada", "Custom view created": "Vista personalizada creada", + "Custom view updated": "Vista personalizada actualizada", "Custom Views": "Vistas personalizadas", "Customization": "Personalización", "Customize columns": "Personalizar columnas", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "Error al analizar una definición de contrato de gestor de conectores", "Failed to parse a contract": "Error al analizar un contrato", "Failed to set draft context.": "Error al establecer el contexto de borrador.", + "Failed to update custom view": "Error al actualizar la vista personalizada", "Failed to update theme": "Error al actualizar el tema", "Failure": "Fallo", "false": "No", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "Actualizar componente: {component_name}", "Update connector": "Actualizar conector", "Update context": "Actualizar contexto", + "Update custom view": "Actualizar vista personalizada", "Update dashboard": "Actualizar el cuadro de mandos", "Update date": "Fecha de actualización", "Update entities": "Actualizar entidades", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index becb4ac09285..08294768fce2 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "Tableaux de bord personnalisés", "Custom view": "Vue personnalisée", "Custom view created": "Vue personnalisée créée", + "Custom view updated": "Vue personnalisée mise à jour", "Custom Views": "Vues personnalisées", "Customization": "Personnalisation", "Customize columns": "Personnaliser les colonnes", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "Échec de l'analyse d'une définition de contrat de gestionnaire de connecteurs", "Failed to parse a contract": "Échec de l'analyse d'un contrat", "Failed to set draft context.": "Échec de la définition du contexte de l'ébauche.", + "Failed to update custom view": "Échec de la mise à jour de la vue personnalisée", "Failed to update theme": "Échec de la mise à jour du thème", "Failure": "Échec", "false": "Faux", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "Mise à jour du composant : {component_name}", "Update connector": "Mise à jour d'un connecteur", "Update context": "Mise à jour du contexte", + "Update custom view": "Mettre à jour la vue personnalisée", "Update dashboard": "Modifier le tableau de bord", "Update date": "Date de mise à jour", "Update entities": "Modifier des entités", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index ad217277deb8..f74cfc3e0c28 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "Dashboard personalizzate | Dashboard", "Custom view": "Vista personalizzata", "Custom view created": "Vista personalizzata creata", + "Custom view updated": "Vista personalizzata aggiornata", "Custom Views": "Viste personalizzate", "Customization": "Personalizzazione", "Customize columns": "Personalizza le colonne", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "Impossibile analizzare una definizione di contratto del gestore di connettori", "Failed to parse a contract": "Errore nell'analisi di un contratto", "Failed to set draft context.": "Impossibile impostare il contesto della bozza.", + "Failed to update custom view": "Impossibile aggiornare la vista personalizzata", "Failed to update theme": "Non è stato possibile aggiornare il tema", "Failure": "Fallimento", "false": "falso", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "Aggiornare il componente: {component_name}", "Update connector": "Aggiornare il connettore", "Update context": "Aggiorna il contesto", + "Update custom view": "Aggiornare la vista personalizzata", "Update dashboard": "Aggiorna la dashboard", "Update date": "Aggiorna la data", "Update entities": "Aggiorna le entità", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 06523ad50a2b..69d4f1facb55 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "カスタムダッシュボード|ダッシュボード", "Custom view": "カスタムビュー", "Custom view created": "カスタムビューの作成", + "Custom view updated": "カスタムビュー更新", "Custom Views": "カスタムビュー", "Customization": "カスタマイズ", "Customize columns": "コラムのカスタマイズ", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "コネクター・マネージャーの契約定義の解析に失敗しました。", "Failed to parse a contract": "契約の解析に失敗しました", "Failed to set draft context.": "ドラフトコンテキストの設定に失敗しました。", + "Failed to update custom view": "カスタムビューの更新に失敗しました", "Failed to update theme": "テーマの更新に失敗しました", "Failure": "失敗", "false": "いいえ", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "コンポーネントを更新します:{component_name}", "Update connector": "コネクタの更新", "Update context": "コンテキストの更新", + "Update custom view": "カスタムビューを更新する", "Update dashboard": "ダッシュボードの更新", "Update date": "更新日", "Update entities": "エンティティの更新", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index 73fdaf0c725f..ae6db00c513b 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "사용자 지정 대시보드 | 대시보드", "Custom view": "사용자 지정 보기", "Custom view created": "사용자 지정 뷰 생성", + "Custom view updated": "사용자 지정 뷰 업데이트됨", "Custom Views": "사용자 지정 뷰", "Customization": "맞춤 설정", "Customize columns": "열 사용자 지정", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "커넥터 관리자 컨트랙트 정의를 구문 분석하지 못했습니다", "Failed to parse a contract": "컨트랙트 파싱에 실패했습니다", "Failed to set draft context.": "초안 컨텍스트를 설정하지 못했습니다.", + "Failed to update custom view": "사용자 지정 보기를 업데이트하지 못했습니다", "Failed to update theme": "테마를 업데이트하지 못했습니다", "Failure": "실패", "false": "아니요", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "컴포넌트 업데이트: {component_name}", "Update connector": "커넥터 업데이트", "Update context": "컨텍스트 업데이트", + "Update custom view": "사용자 지정 보기 업데이트", "Update dashboard": "대시보드 업데이트", "Update date": "날짜 업데이트", "Update entities": "엔터티 업데이트", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index c6a56325a74f..54dd2a3eab71 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "Пользовательские панели | Панели инструментов", "Custom view": "Пользовательское представление", "Custom view created": "Создано пользовательское представление", + "Custom view updated": "Пользовательское представление обновлено", "Custom Views": "Пользовательские представления", "Customization": "Настройка", "Customize columns": "Настройте столбцы", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "Не удалось разобрать определение контракта менеджера коннектора", "Failed to parse a contract": "Не удалось разобрать контракт", "Failed to set draft context.": "Не удалось установить контекст проекта.", + "Failed to update custom view": "Не удалось обновить пользовательское представление", "Failed to update theme": "Не удалось обновить тему", "Failure": "Отказ", "false": "ложь", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "Обновление компонента: {component_name}", "Update connector": "Обновление коннектора", "Update context": "Обновление контекста", + "Update custom view": "Обновить пользовательское представление", "Update dashboard": "Обновление дашборда", "Update date": "Дата обновления", "Update entities": "Обновление сущностей", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index a9682deb139a..3f11176e7128 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -953,6 +953,7 @@ "Custom dashboards | Dashboards": "自定义仪表盘", "Custom view": "自定义视图", "Custom view created": "创建自定义视图", + "Custom view updated": "更新自定义视图", "Custom Views": "自定义视图", "Customization": "定制化", "Customize columns": "自定义列", @@ -1829,6 +1830,7 @@ "Failed to parse a connector manager contract definition": "解析连接器管理器合同定义失败", "Failed to parse a contract": "解析合同失败", "Failed to set draft context.": "设置草稿上下文失败。", + "Failed to update custom view": "更新自定义视图失败", "Failed to update theme": "更新主题失败", "Failure": "失败", "false": "假", @@ -4547,6 +4549,7 @@ "Update component: {component_name}": "更新组件:{component_name}", "Update connector": "更新连接器", "Update context": "更新上下文", + "Update custom view": "更新自定义视图", "Update dashboard": "更新仪表板", "Update date": "更新日期", "Update entities": "更新实体", diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx index 200af32a60b6..39a13f3b4478 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewForm.tsx @@ -11,21 +11,30 @@ import { useEffect, useRef } from 'react'; export interface CustomViewFormInputs { name: string; - description: string | null; + description?: string | null; } export type CustomViewFormInputKeys = keyof CustomViewFormInputs; +const DEFAULT_VALUES: CustomViewFormInputs = { + name: '', + description: null, +}; + interface CustomViewFormProps { onClose: () => void; onSubmit: FormikConfig['onSubmit']; - defaultValues?: CustomViewFormInputs; + onSubmitField: (name: string, value: unknown) => void; + values?: CustomViewFormInputs; + isEdition?: boolean; } const CustomViewForm = ({ onClose, onSubmit, - defaultValues, + onSubmitField, + values, + isEdition = false, }: CustomViewFormProps) => { const { t_i18n } = useFormatter(); @@ -34,9 +43,9 @@ const CustomViewForm = ({ description: Yup.string().nullable(), }); - const initialValues: CustomViewFormInputs = defaultValues ?? { - name: '', - description: null, + const handleFieldSubmit = (setSubmitting: (v: boolean) => void) => (name: string, value: unknown) => { + onSubmitField(name, value); + setSubmitting(false); }; const nameInputRef = useRef(null); useEffect(() => { @@ -45,6 +54,7 @@ const CustomViewForm = ({ } }, []); + const initialValues = values ?? DEFAULT_VALUES; return ( enableReinitialize={true} @@ -52,17 +62,17 @@ const CustomViewForm = ({ initialValues={initialValues} onSubmit={onSubmit} > - {({ submitForm, handleReset, isSubmitting }) => { + {({ submitForm, handleReset, isSubmitting, setSubmitting }) => { return (
- - - - + {!isEdition && ( + + + + + )} ); }} diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx index 9182b2047c0e..77a8e18e6ee7 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewFormDrawer.tsx @@ -1,32 +1,39 @@ import { FormikConfig } from 'formik/dist/types'; import { useNavigate } from 'react-router-dom'; import Drawer from '@components/common/drawer/Drawer'; -import useCustomViewAdd from './useCustomViewAdd'; -import CustomViewForm, { type CustomViewFormInputs } from './CustomViewForm'; import { useFormatter } from '../../../../../components/i18n'; import { handleError, MESSAGING$ } from '../../../../../relay/environment'; +import useCustomViewEdit from './useCustomViewEdit'; +import useCustomViewAdd from './useCustomViewAdd'; +import CustomViewForm, { type CustomViewFormInputs } from './CustomViewForm'; interface CustomViewFormDrawerProps { isOpen: boolean; onClose: () => void; entityType: string; + customView?: { id: string } & CustomViewFormInputs; } const CustomViewFormDrawer = ({ isOpen, onClose, entityType, + customView, }: CustomViewFormDrawerProps) => { const navigate = useNavigate(); const { t_i18n } = useFormatter(); const createTitle = t_i18n('Create custom view'); + const editionTitle = t_i18n('Update custom view'); + const isEdition = !!customView; const [commitAddMutation] = useCustomViewAdd(); + const [commitEditMutation] = useCustomViewEdit(); - const onAdd: FormikConfig['onSubmit'] = ( + const handleSubmitForm: FormikConfig['onSubmit'] = ( values, { setSubmitting, resetForm }, ) => { + if (isEdition) return; commitAddMutation({ variables: { input: { @@ -52,16 +59,35 @@ const CustomViewFormDrawer = ({ }); }; + const handleEditField = (field: string, value: unknown) => { + if (!isEdition) return; + const input: { key: string; value: [unknown] } = { key: field, value: [value] }; + commitEditMutation({ + variables: { id: customView.id, input: [input] }, + onCompleted: () => { + MESSAGING$.notifySuccess(t_i18n('Custom view updated')); + }, + onError: () => { + MESSAGING$.notifyError(t_i18n('Failed to update custom view')); + }, + }); + }; + + const title = isEdition ? editionTitle : createTitle; + return ( <> diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewEdit.ts b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewEdit.ts new file mode 100644 index 000000000000..859ee7e3a3e2 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/useCustomViewEdit.ts @@ -0,0 +1,43 @@ +import { graphql } from 'react-relay'; +import { useState } from 'react'; +import useApiMutation from '../../../../../utils/hooks/useApiMutation'; +import { useCustomViewEdit_Mutation } from './__generated__/useCustomViewEdit_Mutation.graphql'; + +const customViewEditMutation = graphql` + mutation useCustomViewEdit_Mutation($id: ID!, $input: [EditInput!]!) { + customViewEdit(id: $id, input: $input) { + id + name + description + path + } + } +`; + +/** + * Hook handling Custom view edition. + * To edit dashboard-related content use useCustomViewDashboardEdit. + */ +const useCustomViewEdit = () => { + const [mutating, setMutating] = useState(false); + const [commitEditMutation] = useApiMutation(customViewEditMutation); + + const mutation: typeof commitEditMutation = ({ variables, onCompleted, onError }) => { + setMutating(true); + commitEditMutation({ + variables, + onError: (error) => { + setMutating(false); + onError?.(error); + }, + onCompleted: (...args) => { + setMutating(false); + onCompleted?.(...args); + }, + }); + }; + + return [mutation, mutating] as const; +}; + +export default useCustomViewEdit; From 99a1907b2fe3dc740bd77b5433a531afd4770aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Thu, 16 Apr 2026 13:40:31 +0200 Subject: [PATCH 13/17] [frontend] Hide right sidebar when editing custom views --- .../components/settings/customization/Root.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/opencti-platform/opencti-front/src/private/components/settings/customization/Root.tsx b/opencti-platform/opencti-front/src/private/components/settings/customization/Root.tsx index 6c5b1504f182..e38eca224a15 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/customization/Root.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/customization/Root.tsx @@ -27,7 +27,16 @@ const RootCustomization = () => { return ( <> - + + + } + /> + }> Date: Mon, 20 Apr 2026 01:22:48 +0200 Subject: [PATCH 14/17] [backend] Duplicate a custom view --- .../src/schema/relay.schema.graphql | 9 +++++ .../opencti-graphql/src/generated/graphql.ts | 16 +++++++++ .../modules/customView/customView-domain.ts | 33 ++++++++++++++++++- .../modules/customView/customView-resolver.ts | 4 +++ .../src/modules/customView/customView.graphql | 8 +++++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index 22c0d5615d86..894893f12b12 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -10763,6 +10763,7 @@ type Mutation { customViewAdd(input: CustomViewAddInput!): CustomView! customViewEdit(id: ID!, input: [EditInput!]!): CustomView customViewWidgetConfigurationImport(id: ID!, input: ImportConfigurationInput!): CustomView + customViewDuplicate(input: CustomViewDuplicateInput!): CustomView taxiiCollectionAdd(input: TaxiiCollectionAddInput!): TaxiiCollection taxiiCollectionEdit(id: ID!): TaxiiCollectionEditMutations feedAdd(input: FeedAddInput!): Feed @@ -16216,6 +16217,14 @@ input CustomViewAddInput { target_entity_type: String! } +input CustomViewDuplicateInput { + "*Constraints:*\n* Minimal length: `2`\n* Must match format: `not-blank`\n" + name: String! + description: String + manifest: String + target_entity_type: String! +} + type TaxiiCollection { id: ID! name: String diff --git a/opencti-platform/opencti-graphql/src/generated/graphql.ts b/opencti-platform/opencti-graphql/src/generated/graphql.ts index ad8562679609..77c45cb9cd51 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -6462,6 +6462,13 @@ export type CustomViewAddInput = { target_entity_type: Scalars['String']['input']; }; +export type CustomViewDuplicateInput = { + description?: InputMaybe; + manifest?: InputMaybe; + name: Scalars['String']['input']; + target_entity_type: Scalars['String']['input']; +}; + export type CustomViewsDisplayContext = { __typename?: 'CustomViewsDisplayContext'; custom_views_info: Array; @@ -16925,6 +16932,7 @@ export type Mutation = { csvMapperFieldPatch?: Maybe; csvMapperTest?: Maybe; customViewAdd: CustomView; + customViewDuplicate?: Maybe; customViewEdit?: Maybe; customViewWidgetConfigurationImport?: Maybe; dataComponentAdd?: Maybe; @@ -17746,6 +17754,11 @@ export type MutationCustomViewAddArgs = { }; +export type MutationCustomViewDuplicateArgs = { + input: CustomViewDuplicateInput; +}; + + export type MutationCustomViewEditArgs = { id: Scalars['ID']['input']; input: Array; @@ -39012,6 +39025,7 @@ export type ResolversTypes = ResolversObject<{ CurrentConnectorStatusInput: CurrentConnectorStatusInput; CustomView: ResolverTypeWrapper; CustomViewAddInput: CustomViewAddInput; + CustomViewDuplicateInput: CustomViewDuplicateInput; CustomViewsDisplayContext: ResolverTypeWrapper & { custom_views_info: Array }>; CustomViewsSettings: ResolverTypeWrapper & { customViews: Array }>; DataComponent: ResolverTypeWrapper; @@ -40091,6 +40105,7 @@ export type ResolversParentTypes = ResolversObject<{ CurrentConnectorStatusInput: CurrentConnectorStatusInput; CustomView: BasicStoreEntityCustomView; CustomViewAddInput: CustomViewAddInput; + CustomViewDuplicateInput: CustomViewDuplicateInput; CustomViewsDisplayContext: Omit & { custom_views_info: Array }; CustomViewsSettings: Omit & { customViews: Array }; DataComponent: BasicStoreEntityDataComponent; @@ -46626,6 +46641,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; csvMapperTest?: Resolver, ParentType, ContextType, RequireFields>; customViewAdd?: Resolver>; + customViewDuplicate?: Resolver, ParentType, ContextType, RequireFields>; customViewEdit?: Resolver, ParentType, ContextType, RequireFields>; customViewWidgetConfigurationImport?: Resolver, ParentType, ContextType, RequireFields>; dataComponentAdd?: Resolver, ParentType, ContextType, RequireFields>; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts index e621e18a426c..e43ae07cc4d0 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts @@ -2,7 +2,7 @@ import slugify from 'slug'; import { fullEntitiesList, storeLoadById } from '../../database/middleware-loader'; import type { AuthContext, AuthUser } from '../../types/user'; import { ENTITY_TYPE_CUSTOM_VIEW, type BasicStoreEntityCustomView, type StoreEntityCustomView } from './customView-types'; -import { FilterMode, type CustomViewAddInput, type EditInput, type ImportWidgetInput } from '../../generated/graphql'; +import { FilterMode, type CustomViewAddInput, type CustomViewDuplicateInput, type EditInput, type ImportWidgetInput } from '../../generated/graphql'; import { ENTITY_TYPE_CONTAINER_NOTE, ENTITY_TYPE_CONTAINER_OBSERVED_DATA, @@ -240,3 +240,34 @@ export const exportCustomViewWidget = ( ) => { return exportWidget(auth, user, customView, widgetId); }; + +export async function duplicateCustomView( + context: AuthContext, + user: AuthUser, + input: CustomViewDuplicateInput, +) { + const created_at = now(); + const customViewToCreate = { + ...input, + slug: slugify(input.name), + created_at, + updated_at: created_at, + }; + const entity = await createEntity( + context, + user, + customViewToCreate, + ENTITY_TYPE_CUSTOM_VIEW, + ); + const sanitizeElement = { ...input, manifest: undefined }; + await publishUserAction({ + user, + event_type: 'mutation', + event_scope: 'create', + event_access: 'extended', + message: `creates custom view \`${entity.name}\` from custom-named duplication`, + context_data: { id: entity.id, entity_type: ENTITY_TYPE_CUSTOM_VIEW, input: sanitizeElement }, + }); + await notify(BUS_TOPICS[ENTITY_TYPE_CUSTOM_VIEW].ADDED_TOPIC, entity, user); + return entity; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts index 333c69594e36..1e992508a759 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts @@ -9,6 +9,7 @@ import { getCustomViewById, customViewImportWidgetConfiguration, exportCustomViewWidget, + duplicateCustomView, } from './customView-domain'; const customViewResolver: Resolvers = { @@ -44,6 +45,9 @@ const customViewResolver: Resolvers = { customViewWidgetConfigurationImport: (_, { id, input }, context) => { return customViewImportWidgetConfiguration(context, context.user, id, input); }, + customViewDuplicate: (_parent, { input }, context) => { + return duplicateCustomView(context, context.user, input); + }, }, }; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql index d04658de2081..ce401c696f13 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql @@ -33,6 +33,13 @@ input CustomViewAddInput { target_entity_type: String! } +input CustomViewDuplicateInput { + name: String! @constraint(minLength: 2, format: "not-blank") + description: String + manifest: String + target_entity_type: String! +} + # Queries type Query { ## Platform user use cases @@ -62,5 +69,6 @@ type Mutation { customViewAdd(input: CustomViewAddInput!): CustomView! @auth(for: [SETTINGS_SETCUSTOMIZATION]) customViewEdit(id: ID!, input: [EditInput!]!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) customViewWidgetConfigurationImport(id: ID!, input: ImportConfigurationInput!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) + customViewDuplicate(input: CustomViewDuplicateInput!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) } From 7dfc60dc4a813cc946cd3e00f672ff540aaf8e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Mon, 20 Apr 2026 01:23:03 +0200 Subject: [PATCH 15/17] [frontend] Duplicate a custom view --- .../opencti-front/lang/front/de.json | 2 + .../opencti-front/lang/front/en.json | 2 + .../opencti-front/lang/front/es.json | 2 + .../opencti-front/lang/front/fr.json | 2 + .../opencti-front/lang/front/it.json | 2 + .../opencti-front/lang/front/ja.json | 2 + .../opencti-front/lang/front/ko.json | 2 + .../opencti-front/lang/front/ru.json | 2 + .../opencti-front/lang/front/zh.json | 2 + .../CustomViewDuplicationDialog.tsx | 137 ++++++++++++++++++ .../custom_views/CustomViewEditionHeader.tsx | 3 + .../custom_views/CustomViewKebabMenu.tsx | 112 ++++++++++++++ 12 files changed, 270 insertions(+) create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDuplicationDialog.tsx create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewKebabMenu.tsx diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index 306d1340a9dd..2af8cae3a5f7 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "Ein Formular duplizieren", "Duplicate a JSON feed": "Duplizieren eines JSON-Feeds", "Duplicate a JSON mapper": "Duplizieren Sie einen JSON-Mapper", + "Duplicate the custom view": "Duplizieren Sie die benutzerdefinierte Ansicht", "Duplicate the dashboard": "Duplizieren Sie das Dashboard", "Duration": "Dauer", "Dynamic filter": "Dynamischer Filter", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "Der Zustand des Verbinders wurde zurückgesetzt", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "Die Coverage Result Metric zeigt, wie stark eine bestimmte Entität in die Ausführung des AEV-Szenarios involviert war. Die Coverage kann unvollständig sein, wenn einige Injects nicht ausgeführt wurden, wenn Platzhalter nicht aufgelöst wurden oder wenn die Plattform bestimmte Aktionen nicht unterstützt", "The CSV file has been generated with the parameters of the view and is ready for download.": "Die CSV-Datei wurde mit den Parametern der Ansicht erstellt und steht zum Download bereit.", + "The custom view has been duplicated. You can manage it": "Die benutzerdefinierte Ansicht wurde dupliziert. Sie können sie verwalten", "The dashboard has been duplicated. You can manage it": "Das Dashboard wurde dupliziert. Sie können es verwalten", "the dedicated page": "der entsprechenden Seite", "The default groups are:": "Die Standardgruppen sind:", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 1176d2096d3a..79c96de73c17 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "Duplicate a form", "Duplicate a JSON feed": "Duplicate a JSON feed", "Duplicate a JSON mapper": "Duplicate a JSON mapper", + "Duplicate the custom view": "Duplicate the custom view", "Duplicate the dashboard": "Duplicate the dashboard", "Duration": "Duration", "Dynamic filter": "Dynamic filter", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "The connector state has been reset", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions", "The CSV file has been generated with the parameters of the view and is ready for download.": "The CSV file has been generated with the parameters of the view and is ready for download.", + "The custom view has been duplicated. You can manage it": "The custom view has been duplicated. You can manage it", "The dashboard has been duplicated. You can manage it": "The dashboard has been duplicated. You can manage it", "the dedicated page": "the dedicated page", "The default groups are:": "The default groups are:", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index 39af282ac154..527592b8ba70 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "Duplicar un formulario", "Duplicate a JSON feed": "Duplicar un feed JSON", "Duplicate a JSON mapper": "Duplicar un mapeador JSON", + "Duplicate the custom view": "Duplicar la vista personalizada", "Duplicate the dashboard": "Duplicar el salpicadero", "Duration": "Duración", "Dynamic filter": "Filtro dinámico", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "El estado de este conector ha sido reiniciado", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "La métrica de resultado de cobertura muestra en qué medida una entidad específica estuvo implicada en la ejecución del escenario AEV.\\n La cobertura puede ser parcial si no se ejecutaron algunas inyecciones, si no se resolvieron marcadores de posición o si la plataforma no admite determinadas acciones", "The CSV file has been generated with the parameters of the view and is ready for download.": "El fichero CSV se ha generado con los parámetros de la vista y está preparado para ser descargado.", + "The custom view has been duplicated. You can manage it": "La vista personalizada se ha duplicado. Puede gestionarla", "The dashboard has been duplicated. You can manage it": "El tablero ha sido duplicado. puedes gestionarlo", "the dedicated page": "la página dedicada", "The default groups are:": "Los grupos por defecto son:", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index 08294768fce2..9f4d549c3b3b 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "Dupliquer un formulaire", "Duplicate a JSON feed": "Dupliquer un flux JSON", "Duplicate a JSON mapper": "Dupliquer un mappeur JSON", + "Duplicate the custom view": "Dupliquer la vue personnalisée", "Duplicate the dashboard": "Dupliquer le tableau de bord", "Duration": "Durée", "Dynamic filter": "Filtre dynamique", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "L'état du connecteur a été réinitialisé", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "La mesure du résultat de la couverture indique dans quelle mesure une entité spécifique a été impliquée dans l'exécution du scénario AEV. La couverture peut être partielle si certaines injections n'ont pas été exécutées, si les espaces réservés n'ont pas été résolus ou si la plate-forme ne prend pas en charge certaines actions", "The CSV file has been generated with the parameters of the view and is ready for download.": "Le fichier CSV a été généré avec les paramètres de la vue et est prêt pour être téléchargé.", + "The custom view has been duplicated. You can manage it": "La vue personnalisée a été dupliquée. Vous pouvez la gérer", "The dashboard has been duplicated. You can manage it": "Le tableau de bord a été dupliqué. Vous pouvez l'administrer", "the dedicated page": "la page dédiée", "The default groups are:": "Les groupes par défaut sont les suivants", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index f74cfc3e0c28..3bb2ec17d96d 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "Duplicare un modulo", "Duplicate a JSON feed": "Duplicare un feed JSON", "Duplicate a JSON mapper": "Duplicare un mappatore JSON", + "Duplicate the custom view": "Duplicare la vista personalizzata", "Duplicate the dashboard": "Duplica la dashboard", "Duration": "Durata", "Dynamic filter": "Filtro dinamico", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "Il connettore è stato ripristinato", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "La metrica del risultato della copertura mostra quanto una specifica entità sia stata coinvolta nell'esecuzione dello scenario AEV.\\n La copertura può essere parziale se alcune iniezioni non sono state eseguite, se i segnaposto non sono stati risolti o se la piattaforma non supporta alcune azioni", "The CSV file has been generated with the parameters of the view and is ready for download.": "Il file CSV è stato generato con i parametri della vista ed è pronto per il download.", + "The custom view has been duplicated. You can manage it": "La vista personalizzata è stata duplicata. È possibile gestirla", "The dashboard has been duplicated. You can manage it": "La dashboard è stata duplicata. Puoi gestirla", "the dedicated page": "la pagina dedicata", "The default groups are:": "I gruppi predefiniti sono:", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 69d4f1facb55..506e59eec945 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "フォームの複製", "Duplicate a JSON feed": "JSONフィードを複製する", "Duplicate a JSON mapper": "JSONマッパーを複製する", + "Duplicate the custom view": "カスタムビューを複製する", "Duplicate the dashboard": "ダッシュボードの複製", "Duration": "期間", "Dynamic filter": "ダイナミック・フィルター", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "コネクタの状態を初期化しました", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "カバレッジ結果メトリクスは、特定のエンティティがAEVシナリオの実行にどの程度関与したかを示し ます。", "The CSV file has been generated with the parameters of the view and is ready for download.": "指定されたパラメータでCSVが生成され、ダウンロードできるようになりました。", + "The custom view has been duplicated. You can manage it": "カスタムビューが複製されました。管理できる", "The dashboard has been duplicated. You can manage it": "ダッシュボードが複製されました。管理できるよ", "the dedicated page": "専用ページ", "The default groups are:": "デフォルトのグループは以下の通り:", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index ae6db00c513b..d9f4eafa9763 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "양식 복제", "Duplicate a JSON feed": "JSON 피드 복제하기", "Duplicate a JSON mapper": "JSON 매퍼 복제", + "Duplicate the custom view": "사용자 지정 뷰 복제", "Duplicate the dashboard": "대시보드 복제", "Duration": "기간", "Dynamic filter": "동적 필터", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "커넥터 상태가 재설정되었습니다", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "커버리지 결과 메트릭은 특정 엔티티가 AEV 시나리오 실행에 얼마나 관여했는지 보여줍니다.\\n 일부 인젝트가 실행되지 않았거나 자리 표시자가 해결되지 않았거나 플랫폼이 특정 작업을 지원하지 않는 경우 커버리지가 부분적으로 표시될 수 있습니다", "The CSV file has been generated with the parameters of the view and is ready for download.": "CSV 파일이 뷰의 매개변수로 생성되었으며 다운로드할 준비가 되었습니다.", + "The custom view has been duplicated. You can manage it": "사용자 지정 보기가 중복되었습니다. 관리할 수 있습니다", "The dashboard has been duplicated. You can manage it": "대시보드가 복제되었습니다. 관리할 수 있습니다", "the dedicated page": "전용 페이지", "The default groups are:": "기본 그룹은 다음과 같습니다:", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index 54dd2a3eab71..8ec48509990e 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "Дублировать форму", "Duplicate a JSON feed": "Дублировать фид в формате JSON", "Duplicate a JSON mapper": "Дублирование маппера JSON", + "Duplicate the custom view": "Дублировать пользовательское представление", "Duplicate the dashboard": "Дублировать дашборд", "Duration": "Продолжительность", "Dynamic filter": "Динамический фильтр", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "Состояние коннектора было сброшено", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "Метрика результата покрытия показывает, насколько конкретная сущность была задействована в выполнении сценария AEV.\\n Покрытие может быть частичным, если некоторые инжекты не были выполнены, если не были решены placeholders, или если платформа не поддерживает определенные действия", "The CSV file has been generated with the parameters of the view and is ready for download.": "CSV-файл был создан с параметрами представления и готов к загрузке.", + "The custom view has been duplicated. You can manage it": "Пользовательское представление было продублировано. Вы можете управлять им", "The dashboard has been duplicated. You can manage it": "Дашборд был продублирован. Вы можете управлять ею", "the dedicated page": "специальная страница", "The default groups are:": "По умолчанию используются следующие группы:", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index 3f11176e7128..951602a551a0 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -1419,6 +1419,7 @@ "Duplicate a form": "复制表格", "Duplicate a JSON feed": "复制一个 JSON 源", "Duplicate a JSON mapper": "复制一个 JSON 映射器", + "Duplicate the custom view": "复制自定义视图", "Duplicate the dashboard": "复制仪表板", "Duration": "持续时间", "Dynamic filter": "动态过滤器", @@ -4125,6 +4126,7 @@ "The connector state has been reset": "连接器状态已重置", "The Coverage Result Metric shows how much a specific entity was involved in the execution of the AEV scenario.\\n Coverage may be partial if some injects were not executed, if placeholders were not resolved or if the platform does not support certain actions": "覆盖率结果度量显示了特定实体在 AEV 场景执行中的参与程度。如果某些注入未执行、占位符未解析或平台不支持某些操作,则覆盖率可能是部分的", "The CSV file has been generated with the parameters of the view and is ready for download.": "CSV文件已使用视图的参数生成,并已准备好下载。", + "The custom view has been duplicated. You can manage it": "自定义视图已被复制。您可以对其进行管理", "The dashboard has been duplicated. You can manage it": "仪表板已被复制。你可以管理它", "the dedicated page": "专用页面", "The default groups are:": "默认组别为", diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDuplicationDialog.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDuplicationDialog.tsx new file mode 100644 index 000000000000..ed913c373147 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDuplicationDialog.tsx @@ -0,0 +1,137 @@ +import { FunctionComponent, UIEvent, useMemo, useState } from 'react'; +import { graphql, useFragment } from 'react-relay'; +import { Link } from 'react-router-dom'; +import Button from '@common/button/Button'; +import Dialog from '@common/dialog/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import TextField from '@mui/material/TextField'; +import { useFormatter } from '../../../../../components/i18n'; +import { handleError, MESSAGING$ } from '../../../../../relay/environment'; +import stopEvent from '../../../../../utils/domEvent'; +import useApiMutation from '../../../../../utils/hooks/useApiMutation'; +import { CustomViewDuplicationDialog_DuplicateMutation } from './__generated__/CustomViewDuplicationDialog_DuplicateMutation.graphql'; +import { CustomViewDuplicationDialog_Fragment$data, CustomViewDuplicationDialog_Fragment$key } from './__generated__/CustomViewDuplicationDialog_Fragment.graphql'; + +const customViewDuplicationFragment = graphql` + fragment CustomViewDuplicationDialog_Fragment on CustomView { + name + description + manifest + target_entity_type + } +`; + +interface CustomViewDuplicationDialogProps { + data: CustomViewDuplicationDialog_Fragment$key; + displayDuplicate: boolean; + duplicating: boolean; + handleCloseDuplicate: () => void; + setDuplicating: (value: boolean) => void; +} + +const duplicateMutation = graphql` + mutation CustomViewDuplicationDialog_DuplicateMutation( + $input: CustomViewDuplicateInput! + ) { + customViewDuplicate(input: $input) { + id + target_entity_type + } + } +`; +const CustomViewDuplicationDialog: FunctionComponent< + CustomViewDuplicationDialogProps +> = ({ + data, + duplicating, + setDuplicating, + displayDuplicate, + handleCloseDuplicate, +}) => { + const { t_i18n } = useFormatter(); + const customView = useFragment(customViewDuplicationFragment, data); + + const duplicatedCustomViewInitialName = useMemo( + () => `${customView.name} - ${t_i18n('copy')}`, + [t_i18n, customView.name], + ); + const [newName, setNewName] = useState(duplicatedCustomViewInitialName); + const [commitDuplicateCustomView] = useApiMutation( + duplicateMutation, + ); + const submitDashboardDuplication = ( + e: UIEvent, + sourceCustomView: CustomViewDuplicationDialog_Fragment$data, + ) => { + stopEvent(e); + commitDuplicateCustomView({ + variables: { + input: { + name: sourceCustomView.name, + description: sourceCustomView.description, + manifest: sourceCustomView.manifest, + target_entity_type: sourceCustomView.target_entity_type, + }, + }, + onError: (error) => { + handleError(error); + }, + onCompleted: (result) => { + handleCloseDuplicate(); + MESSAGING$.notifySuccess( + + {t_i18n('The custom view has been duplicated. You can manage it')}{' '} + + {t_i18n('here')} + + . + , + ); + }, + }); + }; + + const handleSubmitDuplicate = (e: UIEvent, submittedNewName: string) => { + setDuplicating(true); + submitDashboardDuplication(e, { ...customView, name: submittedNewName }); + }; + + return ( + + { + event.preventDefault(); + setNewName(event.target.value); + }} + /> + + + + + + ); +}; + +export default CustomViewDuplicationDialog; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx index 00090e57c417..8bab5c8a826f 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewEditionHeader.tsx @@ -9,6 +9,7 @@ import CustomViewFormDrawer from './CustomViewFormDrawer'; import useEntityTranslation from '../../../../../utils/hooks/useEntityTranslation'; import DashboardWidgetConfig from 'src/components/dashboard/DashboardWidgetConfig'; import type { DashboardWidget } from '../../../../../components/dashboard/dashboard-types'; +import CustomViewKebabMenu from './CustomViewKebabMenu'; // import ExportButtons from 'src/components/ExportButtons'; const headerFragment = graphql` @@ -17,6 +18,7 @@ const headerFragment = graphql` name description target_entity_type + ...CustomViewKebabMenu_customView } `; @@ -62,6 +64,7 @@ const CustomViewEditionHeader = ({ data, onCreateWidget, onImportWidget }: Custo // exportToPdf={false} // /> } + {}; + +const useDuplicate = (onDuplicate = noop) => { + const [displayDuplicate, setDisplayDuplicate] = useState(false); + const handleCloseDuplicate = () => setDisplayDuplicate(false); + const [duplicating, setDuplicating] = useState(false); + const handleDuplication = () => { + onDuplicate(); + setDisplayDuplicate(true); + }; + + return { + displayDuplicate, + setDisplayDuplicate, + handleCloseDuplicate, + duplicating, + setDuplicating, + handleDuplication, + }; +}; + +const CustomViewKebabMenu = ({ data }: CustomViewKebabMenuProps) => { + const customView = useFragment(kebabMenuFragment, data); + const { t_i18n } = useFormatter(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + const handleClose = () => { + setAnchorEl(null); + }; + + const { + displayDuplicate, + duplicating, + setDuplicating, + handleDuplication, + handleCloseDuplicate, + } = useDuplicate(handleClose); + return ( +
+ + + + + {t_i18n('Duplicate the custom view')} + + +
+ ); +}; + +export default CustomViewKebabMenu; From 30bfc79a3487b780de6731171aa19a6025278c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Sun, 19 Apr 2026 02:21:16 +0200 Subject: [PATCH 16/17] [backend] Delete a custom view --- .../src/schema/relay.schema.graphql | 5 ++-- .../opencti-graphql/src/generated/graphql.ts | 23 ++++++++++++------- .../modules/customView/customView-domain.ts | 17 +++++++++++++- .../modules/customView/customView-resolver.ts | 4 ++++ .../src/modules/customView/customView.graphql | 5 ++-- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/opencti-platform/opencti-front/src/schema/relay.schema.graphql b/opencti-platform/opencti-front/src/schema/relay.schema.graphql index 894893f12b12..92e4bf2711a7 100644 --- a/opencti-platform/opencti-front/src/schema/relay.schema.graphql +++ b/opencti-platform/opencti-front/src/schema/relay.schema.graphql @@ -10764,6 +10764,7 @@ type Mutation { customViewEdit(id: ID!, input: [EditInput!]!): CustomView customViewWidgetConfigurationImport(id: ID!, input: ImportConfigurationInput!): CustomView customViewDuplicate(input: CustomViewDuplicateInput!): CustomView + customViewDelete(id: ID!): ID! taxiiCollectionAdd(input: TaxiiCollectionAddInput!): TaxiiCollection taxiiCollectionEdit(id: ID!): TaxiiCollectionEditMutations feedAdd(input: FeedAddInput!): Feed @@ -16201,12 +16202,12 @@ type CustomView implements InternalObject & BasicObject { type CustomViewsDisplayContext { entity_type: String! - custom_views_info: [CustomView!]! + custom_views_info: [CustomView]! } type CustomViewsSettings { canEntityTypeHaveCustomViews: Boolean! - customViews: [CustomView!]! + customViews: [CustomView]! } input CustomViewAddInput { diff --git a/opencti-platform/opencti-graphql/src/generated/graphql.ts b/opencti-platform/opencti-graphql/src/generated/graphql.ts index 77c45cb9cd51..6f5f4090e2d9 100644 --- a/opencti-platform/opencti-graphql/src/generated/graphql.ts +++ b/opencti-platform/opencti-graphql/src/generated/graphql.ts @@ -6471,14 +6471,14 @@ export type CustomViewDuplicateInput = { export type CustomViewsDisplayContext = { __typename?: 'CustomViewsDisplayContext'; - custom_views_info: Array; + custom_views_info: Array>; entity_type: Scalars['String']['output']; }; export type CustomViewsSettings = { __typename?: 'CustomViewsSettings'; canEntityTypeHaveCustomViews: Scalars['Boolean']['output']; - customViews: Array; + customViews: Array>; }; export type DataComponent = BasicObject & StixCoreObject & StixDomainObject & StixObject & { @@ -16932,6 +16932,7 @@ export type Mutation = { csvMapperFieldPatch?: Maybe; csvMapperTest?: Maybe; customViewAdd: CustomView; + customViewDelete: Scalars['ID']['output']; customViewDuplicate?: Maybe; customViewEdit?: Maybe; customViewWidgetConfigurationImport?: Maybe; @@ -17754,6 +17755,11 @@ export type MutationCustomViewAddArgs = { }; +export type MutationCustomViewDeleteArgs = { + id: Scalars['ID']['input']; +}; + + export type MutationCustomViewDuplicateArgs = { input: CustomViewDuplicateInput; }; @@ -39026,8 +39032,8 @@ export type ResolversTypes = ResolversObject<{ CustomView: ResolverTypeWrapper; CustomViewAddInput: CustomViewAddInput; CustomViewDuplicateInput: CustomViewDuplicateInput; - CustomViewsDisplayContext: ResolverTypeWrapper & { custom_views_info: Array }>; - CustomViewsSettings: ResolverTypeWrapper & { customViews: Array }>; + CustomViewsDisplayContext: ResolverTypeWrapper & { custom_views_info: Array> }>; + CustomViewsSettings: ResolverTypeWrapper & { customViews: Array> }>; DataComponent: ResolverTypeWrapper; DataComponentAddInput: DataComponentAddInput; DataComponentConnection: ResolverTypeWrapper & { edges?: Maybe>> }>; @@ -40106,8 +40112,8 @@ export type ResolversParentTypes = ResolversObject<{ CustomView: BasicStoreEntityCustomView; CustomViewAddInput: CustomViewAddInput; CustomViewDuplicateInput: CustomViewDuplicateInput; - CustomViewsDisplayContext: Omit & { custom_views_info: Array }; - CustomViewsSettings: Omit & { customViews: Array }; + CustomViewsDisplayContext: Omit & { custom_views_info: Array> }; + CustomViewsSettings: Omit & { customViews: Array> }; DataComponent: BasicStoreEntityDataComponent; DataComponentAddInput: DataComponentAddInput; DataComponentConnection: Omit & { edges?: Maybe>> }; @@ -42973,13 +42979,13 @@ export type CustomViewResolvers; export type CustomViewsDisplayContextResolvers = ResolversObject<{ - custom_views_info?: Resolver, ParentType, ContextType>; + custom_views_info?: Resolver>, ParentType, ContextType>; entity_type?: Resolver; }>; export type CustomViewsSettingsResolvers = ResolversObject<{ canEntityTypeHaveCustomViews?: Resolver; - customViews?: Resolver, ParentType, ContextType>; + customViews?: Resolver>, ParentType, ContextType>; }>; export type DataComponentResolvers = ResolversObject<{ @@ -46641,6 +46647,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; csvMapperTest?: Resolver, ParentType, ContextType, RequireFields>; customViewAdd?: Resolver>; + customViewDelete?: Resolver>; customViewDuplicate?: Resolver, ParentType, ContextType, RequireFields>; customViewEdit?: Resolver, ParentType, ContextType, RequireFields>; customViewWidgetConfigurationImport?: Resolver, ParentType, ContextType, RequireFields>; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts index e43ae07cc4d0..20b50a176de6 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-domain.ts @@ -17,7 +17,7 @@ import { ENTITY_TYPE_CONTAINER_FEEDBACK } from '../case/feedback/feedback-types' import { ABSTRACT_STIX_CORE_RELATIONSHIP, ABSTRACT_STIX_CYBER_OBSERVABLE, ABSTRACT_STIX_DOMAIN_OBJECT } from '../../schema/general'; import { schemaTypesDefinition } from '../../schema/schema-types'; import { ENTITY_HASHED_OBSERVABLE_ARTIFACT } from '../../schema/stixCyberObservable'; -import { createEntity, updateAttribute } from '../../database/middleware'; +import { createEntity, updateAttribute, deleteElementById } from '../../database/middleware'; import { now } from '../../utils/format'; import { publishUserAction } from '../../listener/UserActionListener'; import { notify } from '../../database/redis'; @@ -271,3 +271,18 @@ export async function duplicateCustomView( await notify(BUS_TOPICS[ENTITY_TYPE_CUSTOM_VIEW].ADDED_TOPIC, entity, user); return entity; }; + +export const deleteCustomView = async ( + context: AuthContext, + user: AuthUser, + customViewId: string, +) => { + await deleteElementById( + context, + user, + customViewId, + ENTITY_TYPE_CUSTOM_VIEW, + ); + + return customViewId; +}; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts index 1e992508a759..061ab6eb4d19 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView-resolver.ts @@ -10,6 +10,7 @@ import { customViewImportWidgetConfiguration, exportCustomViewWidget, duplicateCustomView, + deleteCustomView, } from './customView-domain'; const customViewResolver: Resolvers = { @@ -48,6 +49,9 @@ const customViewResolver: Resolvers = { customViewDuplicate: (_parent, { input }, context) => { return duplicateCustomView(context, context.user, input); }, + customViewDelete: (_, { id }, context) => { + return deleteCustomView(context, context.user, id); + }, }, }; diff --git a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql index ce401c696f13..f89d81a0812d 100644 --- a/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql +++ b/opencti-platform/opencti-graphql/src/modules/customView/customView.graphql @@ -18,12 +18,12 @@ type CustomView implements InternalObject & BasicObject { type CustomViewsDisplayContext { entity_type: String! - custom_views_info: [CustomView!]! + custom_views_info: [CustomView]! } type CustomViewsSettings { canEntityTypeHaveCustomViews: Boolean! - customViews: [CustomView!]! + customViews: [CustomView]! } input CustomViewAddInput { @@ -70,5 +70,6 @@ type Mutation { customViewEdit(id: ID!, input: [EditInput!]!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) customViewWidgetConfigurationImport(id: ID!, input: ImportConfigurationInput!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) customViewDuplicate(input: CustomViewDuplicateInput!): CustomView @auth(for: [SETTINGS_SETCUSTOMIZATION]) + customViewDelete(id: ID!): ID! @auth(for: [SETTINGS_SETCUSTOMIZATION]) } From 3b7631991d7579ab88106262117eb753204e742e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Wauquier?= Date: Mon, 20 Apr 2026 01:26:07 +0200 Subject: [PATCH 17/17] [frontend] Delete a custom view --- .../opencti-front/lang/front/de.json | 1 + .../opencti-front/lang/front/en.json | 1 + .../opencti-front/lang/front/es.json | 1 + .../opencti-front/lang/front/fr.json | 1 + .../opencti-front/lang/front/it.json | 1 + .../opencti-front/lang/front/ja.json | 1 + .../opencti-front/lang/front/ko.json | 1 + .../opencti-front/lang/front/ru.json | 1 + .../opencti-front/lang/front/zh.json | 1 + .../custom_views/CustomViewRedirector.tsx | 4 +- .../custom_views/CustomViews-types.ts | 3 +- .../components/custom_views/useCustomViews.ts | 7 +- .../custom_views/CustomViewDeletionDialog.tsx | 75 +++++++++++++++++++ .../custom_views/CustomViewKebabMenu.tsx | 20 +++++ .../custom_views/CustomViewsSettings.tsx | 3 +- .../CustomViewsSettingsDataTable.tsx | 2 +- 16 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDeletionDialog.tsx diff --git a/opencti-platform/opencti-front/lang/front/de.json b/opencti-platform/opencti-front/lang/front/de.json index 2af8cae3a5f7..1da702282cfa 100644 --- a/opencti-platform/opencti-front/lang/front/de.json +++ b/opencti-platform/opencti-front/lang/front/de.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "Möchten Sie diesen CSV-Feed löschen?", "Do you want to delete this CSV ingester?": "Möchten Sie diesen CSV-Importer löschen?", "Do you want to delete this CSV mapper?": "Möchten Sie diesen CSV-Mapper löschen?", + "Do you want to delete this custom view?": "Möchten Sie diese benutzerdefinierte Ansicht löschen?", "Do you want to delete this dashboard?": "Möchten Sie dieses Dashboard löschen?", "Do you want to delete this data component?": "Möchten Sie diese Datenkomponente löschen?", "Do you want to delete this data source?": "Möchten Sie diese Datenquelle löschen?", diff --git a/opencti-platform/opencti-front/lang/front/en.json b/opencti-platform/opencti-front/lang/front/en.json index 79c96de73c17..c3a969b30ff8 100644 --- a/opencti-platform/opencti-front/lang/front/en.json +++ b/opencti-platform/opencti-front/lang/front/en.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "Do you want to delete this CSV Feed?", "Do you want to delete this CSV ingester?": "Do you want to delete this CSV ingester?", "Do you want to delete this CSV mapper?": "Do you want to delete this CSV mapper?", + "Do you want to delete this custom view?": "Do you want to delete this custom view?", "Do you want to delete this dashboard?": "Do you want to delete this dashboard?", "Do you want to delete this data component?": "Do you want to delete this data component?", "Do you want to delete this data source?": "Do you want to delete this data source?", diff --git a/opencti-platform/opencti-front/lang/front/es.json b/opencti-platform/opencti-front/lang/front/es.json index 527592b8ba70..b2937efc63c9 100644 --- a/opencti-platform/opencti-front/lang/front/es.json +++ b/opencti-platform/opencti-front/lang/front/es.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "¿Desea eliminar este feed CSV?", "Do you want to delete this CSV ingester?": "¿Quieres eliminar este importador CSV?", "Do you want to delete this CSV mapper?": "¿Desea eliminar este mapeador CSV?", + "Do you want to delete this custom view?": "¿Desea eliminar esta vista personalizada?", "Do you want to delete this dashboard?": "¿Desea eliminar este cuadro de mandos?", "Do you want to delete this data component?": "¿Quieres borrar este componente de datos?", "Do you want to delete this data source?": "¿Quieres eliminar esta fuente de datos?", diff --git a/opencti-platform/opencti-front/lang/front/fr.json b/opencti-platform/opencti-front/lang/front/fr.json index 9f4d549c3b3b..e248de9f0880 100644 --- a/opencti-platform/opencti-front/lang/front/fr.json +++ b/opencti-platform/opencti-front/lang/front/fr.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "Voulez-vous supprimer ce flux CSV ?", "Do you want to delete this CSV ingester?": "Voulez-vous supprimer cet ingéreur CSV ?", "Do you want to delete this CSV mapper?": "Voulez-vous supprimer ce mappeur CSV ?", + "Do you want to delete this custom view?": "Voulez-vous supprimer cette vue personnalisée ?", "Do you want to delete this dashboard?": "Voulez-vous supprimer ce tableau de bord ?", "Do you want to delete this data component?": "Souhaitez-vous supprimer ce composant de données ?", "Do you want to delete this data source?": "Souhaitez-vous supprimer cette source de données ?", diff --git a/opencti-platform/opencti-front/lang/front/it.json b/opencti-platform/opencti-front/lang/front/it.json index 3bb2ec17d96d..d45f33b98e3d 100644 --- a/opencti-platform/opencti-front/lang/front/it.json +++ b/opencti-platform/opencti-front/lang/front/it.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "Vuoi cancellare questo Feed CSV?", "Do you want to delete this CSV ingester?": "Vuoi eliminare questo ingester CSV?", "Do you want to delete this CSV mapper?": "Vuoi eliminare questo mapper CSV?", + "Do you want to delete this custom view?": "Si desidera eliminare questa vista personalizzata?", "Do you want to delete this dashboard?": "Vuoi eliminare questa dashboard?", "Do you want to delete this data component?": "Vuoi eliminare questo componente dati?", "Do you want to delete this data source?": "Vuoi eliminare questa fonte dati?", diff --git a/opencti-platform/opencti-front/lang/front/ja.json b/opencti-platform/opencti-front/lang/front/ja.json index 506e59eec945..c1b308867246 100644 --- a/opencti-platform/opencti-front/lang/front/ja.json +++ b/opencti-platform/opencti-front/lang/front/ja.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "このCSVフィードを削除しますか?", "Do you want to delete this CSV ingester?": "このCSVインガスターを削除しますか?", "Do you want to delete this CSV mapper?": "このCSVマッパーを削除しますか?", + "Do you want to delete this custom view?": "このカスタムビューを削除しますか?", "Do you want to delete this dashboard?": "このダッシュボードを削除しますか?", "Do you want to delete this data component?": "このデータコンポーネントを削除しますか?", "Do you want to delete this data source?": "このデータ ソースを削除しますか?", diff --git a/opencti-platform/opencti-front/lang/front/ko.json b/opencti-platform/opencti-front/lang/front/ko.json index d9f4eafa9763..af7b6f10ded0 100644 --- a/opencti-platform/opencti-front/lang/front/ko.json +++ b/opencti-platform/opencti-front/lang/front/ko.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "이 CSV 피드를 삭제하시겠습니까?", "Do you want to delete this CSV ingester?": "이 CSV 인제스터를 삭제하시겠습니까?", "Do you want to delete this CSV mapper?": "이 CSV 매퍼를 삭제하시겠습니까?", + "Do you want to delete this custom view?": "이 사용자 지정 보기를 삭제하시겠습니까?", "Do you want to delete this dashboard?": "이 대시보드를 삭제하시겠습니까?", "Do you want to delete this data component?": "이 데이터 구성 요소를 삭제하시겠습니까?", "Do you want to delete this data source?": "이 데이터 소스를 삭제하시겠습니까?", diff --git a/opencti-platform/opencti-front/lang/front/ru.json b/opencti-platform/opencti-front/lang/front/ru.json index 8ec48509990e..830e912323a1 100644 --- a/opencti-platform/opencti-front/lang/front/ru.json +++ b/opencti-platform/opencti-front/lang/front/ru.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "Вы хотите удалить этот CSV-канал?", "Do you want to delete this CSV ingester?": "Вы хотите удалить этот CSV-загрузчик?", "Do you want to delete this CSV mapper?": "Вы хотите удалить этот CSV-маппер?", + "Do you want to delete this custom view?": "Хотите ли вы удалить это пользовательское представление?", "Do you want to delete this dashboard?": "Вы хотите удалить этот дашборд?", "Do you want to delete this data component?": "Хотите ли вы удалить этот компонент данных?", "Do you want to delete this data source?": "Хотите ли вы удалить этот источник данных?", diff --git a/opencti-platform/opencti-front/lang/front/zh.json b/opencti-platform/opencti-front/lang/front/zh.json index 951602a551a0..cc396099b36d 100644 --- a/opencti-platform/opencti-front/lang/front/zh.json +++ b/opencti-platform/opencti-front/lang/front/zh.json @@ -1241,6 +1241,7 @@ "Do you want to delete this CSV Feed?": "您想删除此 CSV Feed 吗?", "Do you want to delete this CSV ingester?": "您要删除此CSV导入程序吗?", "Do you want to delete this CSV mapper?": "您要删除此CSV映射吗?", + "Do you want to delete this custom view?": "要删除此自定义视图吗?", "Do you want to delete this dashboard?": "是否要删除此仪表板?", "Do you want to delete this data component?": "是否要删除此数据组件?", "Do you want to delete this data source?": "你想删除这个数据源吗?", diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx b/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx index 6708f84e51f4..c6f992ed1cc9 100644 --- a/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx +++ b/opencti-platform/opencti-front/src/private/components/custom_views/CustomViewRedirector.tsx @@ -10,7 +10,7 @@ interface CustomViewRedirectorProps { } const renderMatch = (info: SlugRedirectHandlerPageInfo) => - ; + ; const CustomViewRedirector = ({ entityType, Fallback }: CustomViewRedirectorProps) => { const { customViews } = useCustomViews(entityType); @@ -18,7 +18,7 @@ const CustomViewRedirector = ({ entityType, Fallback }: CustomViewRedirectorProp (acc, customViewInfo) => ({ ...acc, [customViewInfo.id.replaceAll('-', '')]: customViewInfo, - }), {} as Record, + }), {} as Record, ), [customViews]); return ( [number]['custom_views_info']; +// Excludes deleted items (null or undefined items in the array) +export type CustomViewsInfo = NonNullable[number]['custom_views_info'][number]>; export interface CustomViewManifestConfig { startDate?: string; diff --git a/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts b/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts index 3c024ebbe71b..dd577ac0dbe8 100644 --- a/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts +++ b/opencti-platform/opencti-front/src/private/components/custom_views/useCustomViews.ts @@ -4,7 +4,7 @@ import type { CustomViewsInfo } from './CustomViews-types'; export const CUSTOM_VIEW_TAB_VALUE = 'custom-view'; -function matchPath(customViews: CustomViewsInfo) { +function matchPath(customViews: CustomViewsInfo[]) { return (fullPath: string, basePath: string) => { const current = getCurrentTab(fullPath, basePath); if (customViews.find(({ path }) => path === current)) { @@ -26,8 +26,9 @@ export const useCustomViews = (entityType: string) => { return NO_CUSTOM_VIEWS; } const customViews = customViewsContextForType.custom_views_info ?? []; - const getCurrentCustomViewTab = matchPath(customViews); - const sortedCustomViews = [...customViews].sort( + const nonNullCustomViews = customViews.filter((c) => !!c); + const getCurrentCustomViewTab = matchPath(nonNullCustomViews); + const sortedCustomViews = [...nonNullCustomViews].sort( (lhs, rhs) => lhs.name.localeCompare(rhs.name), ); return { diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDeletionDialog.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDeletionDialog.tsx new file mode 100644 index 000000000000..9d0eeba651f1 --- /dev/null +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewDeletionDialog.tsx @@ -0,0 +1,75 @@ +import { useNavigate } from 'react-router-dom'; +import { graphql } from 'relay-runtime'; +import { useFormatter } from '../../../../../components/i18n'; +import useApiMutation from '../../../../../utils/hooks/useApiMutation'; +import useDeletion from '../../../../../utils/hooks/useDeletion'; +import DeleteDialog from '../../../../../components/DeleteDialog'; +import useEntityTranslation from '../../../../../utils/hooks/useEntityTranslation'; +import { CustomViewDeletionDialog_Mutation } from './__generated__/CustomViewDeletionDialog_Mutation.graphql'; + +const customViewDeletionDialogMutation = graphql` + mutation CustomViewDeletionDialog_Mutation($id: ID!) { + customViewDelete(id: $id) + } +`; + +interface CustomViewDeletionDialogProps { + id: string; + isOpen: boolean; + handleClose: () => void; + target_entity_type: string; +} + +const CustomViewDeletionDialog = ({ + id, + isOpen, + handleClose, + target_entity_type, +}: CustomViewDeletionDialogProps) => { + const { translateEntityType } = useEntityTranslation(); + const { t_i18n } = useFormatter(); + const navigate = useNavigate(); + const deleteSuccessMessage = t_i18n('', { + id: '... successfully deleted', + values: { entity_type: translateEntityType('CustomView') }, + }); + + const [commit] = useApiMutation( + customViewDeletionDialogMutation, + undefined, + { successMessage: deleteSuccessMessage }, + ); + + const deletion = useDeletion({ handleClose }); + const { setDeleting } = deletion; + + const submitDelete = () => { + setDeleting(true); + commit({ + variables: { + id, + }, + updater: (store, data) => { + if (data?.customViewDelete) { + store.delete(data.customViewDelete); + } + }, + onCompleted: () => { + setDeleting(false); + handleClose(); + navigate(`/dashboard/settings/customization/entity_types/${target_entity_type}/custom-views`); + }, + }); + }; + return ( + + ); +}; + +export default CustomViewDeletionDialog; diff --git a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewKebabMenu.tsx b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewKebabMenu.tsx index a10e4b5b0dc2..1bf8fff06e1e 100644 --- a/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewKebabMenu.tsx +++ b/opencti-platform/opencti-front/src/private/components/settings/sub_types/custom_views/CustomViewKebabMenu.tsx @@ -7,6 +7,7 @@ import ToggleButton from '@mui/material/ToggleButton'; import { useFormatter } from '../../../../../components/i18n'; import CustomViewDuplicationDialog from './CustomViewDuplicationDialog'; import type { CustomViewKebabMenu_customView$key } from './__generated__/CustomViewKebabMenu_customView.graphql'; +import CustomViewDeletionDialog from './CustomViewDeletionDialog'; const kebabMenuFragment = graphql` fragment CustomViewKebabMenu_customView on CustomView { @@ -42,6 +43,17 @@ const useDuplicate = (onDuplicate = noop) => { }; }; +const useDelete = (onDelete = noop) => { + const [openDelete, setOpenDelete] = useState(false); + const handleCloseDeletion = () => setOpenDelete(false); + const handleOpenDeletion = () => { + onDelete(); + setOpenDelete(true); + }; + + return { openDelete, handleOpenDeletion, handleCloseDeletion }; +}; + const CustomViewKebabMenu = ({ data }: CustomViewKebabMenuProps) => { const customView = useFragment(kebabMenuFragment, data); const { t_i18n } = useFormatter(); @@ -61,6 +73,7 @@ const CustomViewKebabMenu = ({ data }: CustomViewKebabMenuProps) => { handleDuplication, handleCloseDuplicate, } = useDuplicate(handleClose); + const { openDelete, handleOpenDeletion, handleCloseDeletion } = useDelete(handleClose); return (
{ }} > {t_i18n('Duplicate the custom view')} + {t_i18n('Delete')} + { customViewsFragment, customViewsSettings, ); + const nonDeletedCustomViews = customViews.filter((c) => !!c); const [isDrawerOpen, setDrawerOpen] = useState(false); return ( @@ -56,7 +57,7 @@ const CustomViewsSettings = () => { )} > - + ; interface CustomViewsSettingsDataTableProps { customViews: Readonly;