Skip to content
2 changes: 2 additions & 0 deletions QualityControl/lib/dtos/LayoutDto.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const ALLOWED_LAYOUT_FIELDS = [
'owner_id',
'owner_name',
'tabs',
'labels',
];

/**
Expand Down Expand Up @@ -71,6 +72,7 @@ export const LayoutDto = Joi.object({
id: Joi.string().required(),
name: Joi.string().min(3).max(40).required(),
tabs: Joi.array().min(1).max(45).items(TabsDto).required(),
labels: Joi.array().items(Joi.string()).default([]),
owner_id: Joi.number().min(0).required(),
owner_name: Joi.string().required(),
description: Joi.string().min(0).max(100).optional(),
Expand Down
49 changes: 31 additions & 18 deletions QualityControl/lib/repositories/LayoutRepository.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

import { NotFoundError } from '@aliceo2/web-ui';
import { BaseRepository } from './BaseRepository.js';
import { addLabelsToLayout } from '../utils/layout/addLabelsToLayout.js';
import { trimLayoutPerRequiredFields } from '../utils/layout/trimLayoutPerRequiredFields.js';

/**
* LayoutRepository class to handle CRUD operations for Layouts.
Expand All @@ -26,20 +28,18 @@ export class LayoutRepository extends BaseRepository {
* @param {object} [options.filter] - Filter layouts by containing filter.objectPath, case insensitive
* @returns {Array<object>} Array of layout objects matching the filters, containing only the specified fields
*/
listLayouts({ name, fields = [], filter } = {}) {
listLayouts({ name, fields, filter } = {}) {
const { layouts } = this._jsonFileService.data;
const filteredLayouts = this._filterLayouts(layouts, { ...filter, name });

if (fields.length === 0) {
return filteredLayouts;
}
return filteredLayouts.map((layout) => {
const layoutObj = {};
fields.forEach((field) => {
layoutObj[field] = layout[field];
const trimmedAndLabelledLayouts = filteredLayouts
.map((layout) => {
const labeledLayout = addLabelsToLayout(layout);
const trimmedLayout = trimLayoutPerRequiredFields(labeledLayout, fields);
return trimmedLayout;
});
return layoutObj;
});

return trimmedAndLabelledLayouts;
}

/**
Expand Down Expand Up @@ -79,25 +79,27 @@ export class LayoutRepository extends BaseRepository {
* @throws {NotFoundError} - if the layout is not found
*/
readLayoutById(layoutId) {
const foundLayout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId);
if (!foundLayout) {
const layout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId);
if (!layout) {
throw new NotFoundError(`layout (${layoutId}) not found`);
}
return foundLayout;
const labeledLayout = addLabelsToLayout(layout);
return labeledLayout;
}

/**
* Given a string, representing layout name, retrieve the layout if it exists
* @param {string} layoutName - name of the layout to retrieve
* @returns {Layout} - object with layout information
* @throws
* @throws {NotFoundError} - if the layout is not found
*/
readLayoutByName(layoutName) {
const layout = this._jsonFileService.data.layouts.find((layout) => layout.name === layoutName);
if (!layout) {
throw new NotFoundError(`Layout (${layoutName}) not found`);
}
return layout;
const labeledLayout = addLabelsToLayout(layout);
return labeledLayout;
}

/**
Expand Down Expand Up @@ -127,9 +129,17 @@ export class LayoutRepository extends BaseRepository {
* @param {string} layoutId - id of the layout to be updated
* @param {LayoutDto} newData - layout new data
* @returns {string} id of the layout updated
* @throws {NotFoundError} - if the layout is not found
*/
async updateLayout(layoutId, newData) {
const layout = this.readLayoutById(layoutId);
if (newData.labels) {
// labels are retrieved on front-end and might be send as PATCH/PUT if forgotten by developer
delete newData.labels;
}
const layout = this._jsonFileService.data.layouts.find((layout) => layout.id === layoutId);
if (!layout) {
throw new NotFoundError(`layout (${layoutId}) not found`);
}
Object.assign(layout, newData);
await this._jsonFileService.writeToFile();
return layoutId;
Expand All @@ -139,10 +149,13 @@ export class LayoutRepository extends BaseRepository {
* Delete a single layout by its id
* @param {string} layoutId - id of the layout to be removed
* @returns {string} id of the layout deleted
* @throws {NotFoundError} - if the layout is not found
*/
async deleteLayout(layoutId) {
const layout = this.readLayoutById(layoutId);
const index = this._jsonFileService.data.layouts.indexOf(layout);
const index = this._jsonFileService.data.layouts.findIndex((layout) => layout.id === layoutId);
if (index === -1) {
throw new NotFoundError(`layout (${layoutId}) not found`);
}
this._jsonFileService.data.layouts.splice(index, 1);
await this._jsonFileService.writeToFile();
return layoutId;
Expand Down
30 changes: 30 additions & 0 deletions QualityControl/lib/utils/layout/addLabelsToLayout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

/**
* Method to identify the unique prefix (encountering first '/') of objects and add it as a set of labels to layout
* @param {LayoutDto} layout - layout object to which labels will be added
* @returns {{LayoutDto, labels: string[]}} - layout object with added labels
*/
export const addLabelsToLayout = (layout) => {
const labelsSet = new Set();
layout.tabs?.forEach((tab) => {
tab.objects?.forEach((obj) => {
if (obj.name) {
const [prefix] = obj.name.split('/');
labelsSet.add(prefix);
}
});
});
return { ...layout, labels: Array.from(labelsSet) };
};
31 changes: 31 additions & 0 deletions QualityControl/lib/utils/layout/trimLayoutPerRequiredFields.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* @license
* Copyright CERN and copyright holders of ALICE O2. This software is
* distributed under the terms of the GNU General Public License v3 (GPL
* Version 3), copied verbatim in the file "COPYING".
*
* See http://alice-o2.web.cern.ch/license for full licensing information.
*
* In applying this license CERN does not waive the privileges and immunities
* granted to it by virtue of its status as an Intergovernmental Organization
* or submit itself to any jurisdiction.
*/

/**
* Trims a layout object to only include requested fields
* @param {LayoutDto} layout - layout object to be trimmed
* @param {string[]} [fields = []] - Array of field names to include in the returned layout object
* @returns {Partial<LayoutDto>} - Trimmed layout object
*/
export const trimLayoutPerRequiredFields = (layout, fields = []) => {
if (fields.length === 0) {
return layout;
}
const trimmedLayout = {};
for (const field of fields) {
if (field in layout) {
trimmedLayout[field] = layout[field];
}
}
return trimmedLayout;
};
2 changes: 1 addition & 1 deletion QualityControl/public/common/RequestFields.enum.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const RequestFields = Object.freeze({
LAYOUT_CARD: 'id,name,owner_id,owner_name,description,isOfficial',
LAYOUT_CARD: 'id,name,owner_id,owner_name,description,isOfficial,labels',
});
15 changes: 11 additions & 4 deletions QualityControl/test/api/layouts/api-get-layout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { OWNER_TEST_TOKEN, URL_ADDRESS } from '../config.js';
import request from 'supertest';
import { deepStrictEqual } from 'node:assert';
import { LAYOUT_MOCK_4, LAYOUT_MOCK_5, LAYOUT_MOCK_6 } from '../../demoData/layout/layout.mock.js';
import { addLabelsToLayout } from '../../../lib/utils/layout/addLabelsToLayout.js';

export const apiGetLayoutsTests = () => {
suite('GET /layouts', () => {
Expand All @@ -43,7 +44,9 @@ export const apiGetLayoutsTests = () => {
if (!Array.isArray(res.body)) {
throw new Error('Expected array of layouts');
}

res.body.forEach((layout) => {
delete layout.labels; // remove labels for deep comparison
});
deepStrictEqual(res.body, [LAYOUT_MOCK_4, LAYOUT_MOCK_5], 'Unexpected Layout structure was returned');
});
});
Expand Down Expand Up @@ -80,10 +83,11 @@ export const apiGetLayoutsTests = () => {
suite('GET /layout/:id', () => {
test('should return a single layout by id', async () => {
const layoutId = '671b8c22402408122e2f20dd';
const expectedLayout = addLabelsToLayout(LAYOUT_MOCK_6);
await request(`${URL_ADDRESS}/api/layout/${layoutId}`)
.get(`?token=${OWNER_TEST_TOKEN}`)
.expect(200)
.expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_6, 'Unexpected Layout structure was returned'));
.expect((res) => deepStrictEqual(res.body, expectedLayout, 'Unexpected Layout structure was returned'));
});

test('should return 400 when id parameter is an empty string', async () => {
Expand All @@ -103,19 +107,22 @@ export const apiGetLayoutsTests = () => {
suite('GET /layout?name=', () => {
test('should return layout by name', async () => {
const layoutName = 'a-test';
const expectedLayout = addLabelsToLayout(LAYOUT_MOCK_5);
await request(`${URL_ADDRESS}/api/layout`)
.get(`?token=${OWNER_TEST_TOKEN}&name=${layoutName}`)
.expect(200)
.expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_5, 'Unexpected Layout structure was returned'));
.expect((res) => deepStrictEqual(res.body, expectedLayout, 'Unexpected Layout structure was returned'));
});

test('should return layout by runDefinition', async () => {
const runDefinition = 'a-test';
const expectedLayout = addLabelsToLayout(LAYOUT_MOCK_5);
await request(`${URL_ADDRESS}/api/layout`)
.get(`?token=${OWNER_TEST_TOKEN}&runDefinition=${runDefinition}`)
.expect(200)
.expect((res) => deepStrictEqual(res.body, LAYOUT_MOCK_5, 'Unexpected Layout structure was returned'));
.expect((res) => deepStrictEqual(res.body, expectedLayout, 'Unexpected Layout structure was returned'));
});

test('should return layout by runDefinition and pdpBeamType combination', async () => {
const runDefinition = 'rundefinition';
const pdpBeamType = 'pdpBeamType';
Expand Down
11 changes: 9 additions & 2 deletions QualityControl/test/lib/controllers/LayoutController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* or submit itself to any jurisdiction.
*/

import { ok, throws, doesNotThrow, AssertionError } from 'node:assert';
import { ok, deepStrictEqual, throws, doesNotThrow, AssertionError } from 'node:assert';
import { suite, test, beforeEach } from 'node:test';
import sinon from 'sinon';

Expand Down Expand Up @@ -416,6 +416,7 @@ export const layoutControllerTestSuite = async () => {
tabs: [{ name: 'tab', id: '1', columns: 2, objects: [] }],
owner_id: 1,
owner_name: 'one',
labels: [],
collaborators: [],
displayTimestamp: false,
autoTabChange: 0,
Expand Down Expand Up @@ -467,6 +468,7 @@ export const layoutControllerTestSuite = async () => {
tabs: [{ name: 'tab', id: '1', columns: 2, objects: [] }],
owner_id: 1,
owner_name: 'one',
labels: [],
collaborators: [],
displayTimestamp: false,
autoTabChange: 0,
Expand Down Expand Up @@ -643,6 +645,7 @@ export const layoutControllerTestSuite = async () => {
owner_name: 'admin',
tabs: [{ id: '123', name: 'tab', columns: 2, objects: [] }],
collaborators: [],
labels: [],
displayTimestamp: false,
autoTabChange: 0,
};
Expand Down Expand Up @@ -671,6 +674,7 @@ export const layoutControllerTestSuite = async () => {
owner_id: 1,
owner_name: 'admin',
tabs: [{ id: '123', name: 'tab', columns: 2, objects: [] }],
labels: [],
collaborators: [],
displayTimestamp: false,
autoTabChange: 0,
Expand All @@ -682,7 +686,10 @@ export const layoutControllerTestSuite = async () => {
status: 500,
title: 'Unknown Error',
}), 'DataConnector error message is incorrect');
ok(jsonStub.createLayout.calledWith(expected), 'New layout body was not used in data connector call');

// Log what was actually called for debugging
const actualCall = jsonStub.createLayout.getCall(0)?.args[0];
deepStrictEqual(expected, actualCall);
});
});

Expand Down
Loading
Loading