Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
[backend/frontend] Address PR review feedback for CSV Feed neighbor r…
…esolution (#14680)

- Extract relationship utils to shared Relation.ts (reuse getRelationshipTypesForEntityType, add getTargetTypesForRelationship)

- Add frontend validation preventing form submit when list separator collides with CSV separator

- Fix hardcoded comma in extractAttributeFromEntity to use configurable separator

- Simplify mapping validation checks

- Add unit tests for neighbor-based CSV line generation (list, first-match, empty)

- Add i18n key for separator collision error message

Made-with: Cursor
  • Loading branch information
SamuelHassine committed Mar 4, 2026
commit 83c012dcb9188c182f795aa4f3fccfdcb7669643
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "Mehrere Felder (+ Taste)",
"Multiple Mode": "Mehrfacher Modus",
"Must be a valid URL": "Muss eine gültige URL sein",
"Must differ from CSV separator": "Muss sich vom CSV-Trennzeichen unterscheiden",
"Mutex": "Mutex",
"My CSV file contains headers": "Meine CSV-Datei enthält Kopfzeilen",
"My saved filter": "Mein gespeicherter Filter",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "Multiple fields (+ button)",
"Multiple Mode": "Multiple Mode",
"Must be a valid URL": "Must be a valid URL",
"Must differ from CSV separator": "Must differ from CSV separator",
"Mutex": "Mutex",
"My CSV file contains headers": "My CSV file contains headers",
"My saved filter": "My saved filter",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "Campos múltiples (botón +)",
"Multiple Mode": "Modo múltiple",
"Must be a valid URL": "Debe ser una URL válida",
"Must differ from CSV separator": "Debe diferir del separador CSV",
"Mutex": "Mutex",
"My CSV file contains headers": "Mi archivo CSV contiene encabezados",
"My saved filter": "Mi filtro guardado",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "Champs multiples (bouton +)",
"Multiple Mode": "Mode multiple",
"Must be a valid URL": "Doit être une URL valide",
"Must differ from CSV separator": "Doit différer du séparateur CSV",
"Mutex": "Mutex",
"My CSV file contains headers": "Mon fichier CSV contient des en-têtes",
"My saved filter": "Mon filtre sauvegardé",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "Campi multipli (pulsante +)",
"Multiple Mode": "Modalità multiple",
"Must be a valid URL": "Deve essere un URL valido",
"Must differ from CSV separator": "Deve differire dal separatore CSV",
"Mutex": "Mutex",
"My CSV file contains headers": "Il mio file CSV contiene intestazioni",
"My saved filter": "Il mio filtro salvato",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "複数フィールド(+ボタン)",
"Multiple Mode": "複数のモード",
"Must be a valid URL": "有効なURLであること",
"Must differ from CSV separator": "CSVセパレータと異なる必要があります",
"Mutex": "ミューテックス",
"My CSV file contains headers": "CSVファイルにヘッダーが含まれています",
"My saved filter": "保存したフィルター",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "여러 필드(+ 버튼)",
"Multiple Mode": "다중 모드",
"Must be a valid URL": "유효한 URL이어야 합니다",
"Must differ from CSV separator": "CSV 구분 기호와 달라야 합니다",
"Mutex": "뮤텍스",
"My CSV file contains headers": "내 CSV 파일에 헤더가 포함되어 있습니다",
"My saved filter": "내 저장된 필터",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "Несколько полей (кнопка +)",
"Multiple Mode": "Множественный режим",
"Must be a valid URL": "Должен быть действительным URL",
"Must differ from CSV separator": "Должен отличаться от разделителя CSV",
"Mutex": "Mutex",
"My CSV file contains headers": "Мой CSV-файл содержит заголовки",
"My saved filter": "Мой сохраненный фильтр",
Expand Down
1 change: 1 addition & 0 deletions opencti-platform/opencti-front/lang/front/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -2731,6 +2731,7 @@
"Multiple fields (+ button)": "多个字段(+ 按钮)",
"Multiple Mode": "多种模式",
"Must be a valid URL": "必须是有效的 URL",
"Must differ from CSV separator": "必须与CSV分隔符不同",
"Mutex": "互斥体",
"My CSV file contains headers": "我的CSV文件包含标题",
"My saved filter": "我保存的过滤器",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import useAuth, { FilterDefinition } from '../../../../utils/hooks/useAuth';
import CreateEntityControlledDial from '../../../../components/CreateEntityControlledDial';
import FormButtonContainer from '../../../../components/common/form/FormButtonContainer';
import { useTheme } from '@mui/material/styles';
import { getRelationshipTypesForEntityType, getTargetTypesForRelationship } from '../../../../utils/Relation';

export const feedCreationAllTypesQuery = graphql`
query FeedCreationAllTypesQuery {
Expand Down Expand Up @@ -173,29 +174,6 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
const theme = useTheme();
const { schema } = useAuth();

const getRelationshipTypesForEntity = (entityType: string) => {
const relTypes = new Set<string>();
schema.schemaRelationsTypesMapping.forEach((values, key) => {
if (key.startsWith(`${entityType}_`) || key.endsWith(`_${entityType}`)) {
values.forEach((v: string) => relTypes.add(v));
}
});
relTypes.add('related-to');
return Array.from(relTypes).sort();
};

const getTargetTypesForRelationship = (entityType: string, relType: string) => {
const targets = new Set<string>();
schema.schemaRelationsTypesMapping.forEach((values, key) => {
if (values.includes(relType) || relType === 'related-to') {
const [from, to] = key.split('_');
if (from === entityType) targets.add(to);
if (to === entityType) targets.add(from);
}
});
return Array.from(targets).sort();
};

const [selectedTypes, setSelectedTypes] = useState(feed?.feed_types ?? []);
const [filters, helpers] = useFiltersState(deserializeFilterGroupForFrontend(feed?.filters) ?? emptyFilterGroup);

Expand Down Expand Up @@ -318,7 +296,7 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
return false;
}
const invalidMappings = R.values(feedAttribute.mappings).filter((m) => {
if (!m.type || m.type.length === 0 || !m.attribute || m.attribute.length === 0) return true;
if (!m.type || !m.attribute) return true;
if (m.relationship_type && !m.target_entity_type) return true;
if (!m.relationship_type && m.target_entity_type) return true;
return false;
Expand All @@ -328,6 +306,17 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
return true;
};

const hasSeparatorCollision = (csvSeparator: string) => {
return R.values(feedAttributes).some((attr) => {
const hasRelMapping = R.values(attr?.mappings || {}).some((m) => !!m?.relationship_type);
if (!hasRelMapping) return false;
const strategy = attr?.multi_match_strategy || 'list';
if (strategy !== 'list') return false;
const listSep = attr?.multi_match_separator ?? ',';
return listSep === csvSeparator;
});
};

const handleAddAttribute = () => {
const allKeys = Object.keys(feedAttributes);
const lastKey = R.last(allKeys);
Expand Down Expand Up @@ -662,6 +651,8 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
onChange={(event) => handleChangeMultiMatchSeparator(i, event.target.value)}
sx={{ width: 100 }}
slotProps={{ htmlInput: { maxLength: 3 } }}
error={(feedAttributes[i]?.multi_match_separator ?? ',') === values.separator}
helperText={(feedAttributes[i]?.multi_match_separator ?? ',') === values.separator ? t_i18n('Must differ from CSV separator') : undefined}
/>
)}
</>
Expand Down Expand Up @@ -695,7 +686,7 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
value={currentMapping?.relationship_type || ''}
onChange={(event) => handleChangeNeighborMapping(i, selectedType, 'relationship_type', event.target.value)}
>
{getRelationshipTypesForEntity(selectedType).map((rt) => (
{getRelationshipTypesForEntityType(selectedType, schema).sort().map((rt) => (
<MenuItem key={rt} value={rt}>
{t_i18n(`relationship_${rt}`)}
</MenuItem>
Expand All @@ -711,7 +702,7 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
onChange={(event) => handleChangeNeighborMapping(i, selectedType, 'target_entity_type', event.target.value)}
>
{currentMapping?.relationship_type
&& getTargetTypesForRelationship(selectedType, currentMapping.relationship_type).map((tt) => (
&& getTargetTypesForRelationship(selectedType, currentMapping.relationship_type, schema.schemaRelationsTypesMapping).map((tt) => (
<MenuItem key={tt} value={tt}>
{t_i18n(`entity_${tt}`)}
</MenuItem>
Expand Down Expand Up @@ -824,7 +815,7 @@ const FeedCreation: FunctionComponent<FeedCreationFormProps> = (props) => {
</Button>
<Button
onClick={submitForm}
disabled={isSubmitting || !areAttributesValid()}
disabled={isSubmitting || !areAttributesValid() || hasSeparatorCollision(values.separator)}
>
{isDuplicated ? t_i18n('Duplicate') : t_i18n('Create')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import useFiltersState from '../../../../utils/filters/useFiltersState';
import useAttributes from '../../../../utils/hooks/useAttributes';
import useAuth from '../../../../utils/hooks/useAuth';
import { useTheme } from '@mui/material/styles';
import { getRelationshipTypesForEntityType, getTargetTypesForRelationship } from '../../../../utils/Relation';

const styles = (theme) => ({
header: {
Expand Down Expand Up @@ -160,28 +161,6 @@ const FeedEditionContainer = (props) => {
const { ignoredAttributesInFeeds } = useAttributes();
const { schema } = useAuth();

const getRelationshipTypesForEntity = (entityType) => {
const relTypes = new Set();
schema.schemaRelationsTypesMapping.forEach((values, key) => {
if (key.startsWith(`${entityType}_`) || key.endsWith(`_${entityType}`)) {
values.forEach((v) => relTypes.add(v));
}
});
relTypes.add('related-to');
return Array.from(relTypes).sort();
};

const getTargetTypesForRelationship = (entityType, relType) => {
const targets = new Set();
schema.schemaRelationsTypesMapping.forEach((values, key) => {
if (values.includes(relType) || relType === 'related-to') {
const [from, to] = key.split('_');
if (from === entityType) targets.add(to);
if (to === entityType) targets.add(from);
}
});
return Array.from(targets).sort();
};
const [selectedTypes, setSelectedTypes] = useState(feed.feed_types);
const [filters, helpers] = useFiltersState(deserializeFilterGroupForFrontend(feed.filters));
const [feedAttributes, setFeedAttributes] = useState({
Expand Down Expand Up @@ -272,7 +251,7 @@ const FeedEditionContainer = (props) => {
return false;
}
const invalidMappings = R.values(feedAttribute.mappings).filter((m) => {
if (!m.type || m.type.length === 0 || !m.attribute || m.attribute.length === 0) return true;
if (!m.type || !m.attribute) return true;
if (m.relationship_type && !m.target_entity_type) return true;
if (!m.relationship_type && m.target_entity_type) return true;
return false;
Expand All @@ -282,6 +261,17 @@ const FeedEditionContainer = (props) => {
return true;
};

const hasSeparatorCollision = (csvSeparator) => {
return R.values(feedAttributes).some((attr) => {
const hasRelMapping = R.values(attr?.mappings || {}).some((m) => !!m?.relationship_type);
if (!hasRelMapping) return false;
const strategy = attr?.multi_match_strategy || 'list';
if (strategy !== 'list') return false;
const listSep = attr?.multi_match_separator ?? ',';
return listSep === csvSeparator;
});
};

const handleAddAttribute = () => {
const newKey = R.last(Object.keys(feedAttributes)) + 1;
setFeedAttributes(R.assoc(newKey, {}, feedAttributes));
Expand Down Expand Up @@ -605,6 +595,8 @@ const FeedEditionContainer = (props) => {
onChange={(event) => handleChangeMultiMatchSeparator(i, event.target.value)}
sx={{ width: 100 }}
inputProps={{ maxLength: 3 }}
error={(feedAttributes[i]?.multi_match_separator ?? ',') === values.separator}
helperText={(feedAttributes[i]?.multi_match_separator ?? ',') === values.separator ? t_i18n('Must differ from CSV separator') : undefined}
/>
)}
</>
Expand Down Expand Up @@ -638,7 +630,7 @@ const FeedEditionContainer = (props) => {
value={currentMapping?.relationship_type || ''}
onChange={(event) => handleChangeNeighborMapping(i, selectedType, 'relationship_type', event.target.value)}
>
{getRelationshipTypesForEntity(selectedType).map((rt) => (
{getRelationshipTypesForEntityType(selectedType, schema).sort().map((rt) => (
<MenuItem key={rt} value={rt}>
{t_i18n(`relationship_${rt}`)}
</MenuItem>
Expand All @@ -654,7 +646,7 @@ const FeedEditionContainer = (props) => {
onChange={(event) => handleChangeNeighborMapping(i, selectedType, 'target_entity_type', event.target.value)}
>
{currentMapping?.relationship_type
&& getTargetTypesForRelationship(selectedType, currentMapping.relationship_type).map((tt) => (
&& getTargetTypesForRelationship(selectedType, currentMapping.relationship_type, schema.schemaRelationsTypesMapping).map((tt) => (
<MenuItem key={tt} value={tt}>
{t_i18n(`entity_${tt}`)}
</MenuItem>
Expand Down Expand Up @@ -770,7 +762,7 @@ const FeedEditionContainer = (props) => {
</Button>
<Button
onClick={submitForm}
disabled={isSubmitting || !areAttributesValid()}
disabled={isSubmitting || !areAttributesValid() || hasSeparatorCollision(values.separator)}
classes={{ root: classes.button }}
>
{t_i18n('Update')}
Expand Down
16 changes: 16 additions & 0 deletions opencti-platform/opencti-front/src/utils/Relation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,19 @@ export const getRelationshipTypesForEntityType = (entityType: string, schema: Sc
uniqRelationshipList.add(DEFAULT_RELATION);
return Array.from(uniqRelationshipList);
};

export const getTargetTypesForRelationship = (
entityType: string,
relType: string,
schemaRelationsTypesMapping: Map<string, readonly string[]>,
): string[] => {
const targets = new Set<string>();
schemaRelationsTypesMapping.forEach((values, key) => {
if (values.includes(relType) || relType === DEFAULT_RELATION) {
const [from, to] = key.split('_');
if (from === entityType) targets.add(to);
if (to === entityType) targets.add(from);
}
});
return Array.from(targets).sort();
};
6 changes: 3 additions & 3 deletions opencti-platform/opencti-graphql/src/http/httpRollingFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const escapeCsvField = (separator: string, data: string) => {

const neighborKey = (relType: string, targetType: string) => `${relType}:${targetType}`;

const extractAttributeFromEntity = (entity: BasicStoreBase, attributePath: string): string => {
const extractAttributeFromEntity = (entity: BasicStoreBase, attributePath: string, listSeparator = ','): string => {
const isComplexKey = attributePath.includes('.');
const baseKey = isComplexKey ? attributePath.split('.')[0] : attributePath;
const data = (entity as any)[baseKey];
Expand All @@ -59,7 +59,7 @@ const extractAttributeFromEntity = (entity: BasicStoreBase, attributePath: strin
const dictInnerData = data[innerKey.toUpperCase()];
return isNotEmptyField(dictInnerData) ? String(dictInnerData) : '';
}
if (Array.isArray(data)) return data.join(',');
if (Array.isArray(data)) return data.join(listSeparator);
if (typeof data === 'object') return JSON.stringify(data);
return String(data);
};
Expand Down Expand Up @@ -235,7 +235,7 @@ export const buildCsvLines = (elements: any[], feed: BasicStoreEntityFeed, neigh
const targetEntities = strategy === 'first' ? [neighbors[0]] : neighbors;
const multiSep = attribute.multi_match_separator ?? ',';
const values = targetEntities
.map((n) => extractAttributeFromEntity(n, mapping.attribute))
.map((n) => extractAttributeFromEntity(n, mapping.attribute, multiSep))
.filter((v) => v.length > 0);
dataElements.push(escapeCsvField(separator, values.join(multiSep)));
}
Expand Down
Loading
Loading