From 647e4a56cc4d808ce7426b68347640c374d07afc Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 11:42:19 +0200 Subject: [PATCH 1/2] feat(pages): add the page and widget endpoints for applications api/2 endpoints to list and read the pages of an application, create, retitle and delete grid pages, and save the widgets of a page with their layout in one go. The start page cannot be deleted. Widgets are checked against the schemas of their type. A data widget names a node of the application, or a table or view that is added as a node after the same manage check the application update uses. Saving fills the content order from the positions. The widget types are served from /api/2/widget-types. Assisted-by: ClaudeCode:claude-opus-5-5 --- appinfo/routes.php | 7 + lib/Controller/ContextPageController.php | 234 +++ lib/Db/Page.php | 1 + lib/Db/PageMapper.php | 17 + lib/ResponseDefinitions.php | 40 + lib/Service/GridWidgetTypes.php | 180 +- lib/Service/PageService.php | 362 ++++ openapi.json | 1757 +++++++++++++++++ src/types/openapi/openapi.ts | 800 +++++++- .../Controller/ContextPageControllerTest.php | 134 ++ tests/unit/Db/ContextMapperTest.php | 9 + tests/unit/Service/GridWidgetTypesTest.php | 93 + tests/unit/Service/PageServiceTest.php | 285 +++ 13 files changed, 3915 insertions(+), 4 deletions(-) create mode 100644 lib/Controller/ContextPageController.php create mode 100644 lib/Service/PageService.php create mode 100644 tests/unit/Controller/ContextPageControllerTest.php create mode 100644 tests/unit/Service/GridWidgetTypesTest.php create mode 100644 tests/unit/Service/PageServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index ad9b79e77e..11a4d95daf 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -160,6 +160,13 @@ ['name' => 'Context#importScheme', 'url' => '/api/2/contexts/{contextId}/scheme/import', 'verb' => 'POST'], ['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'], ['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'], + ['name' => 'ContextPage#index', 'url' => '/api/2/contexts/{contextId}/pages', 'verb' => 'GET'], + ['name' => 'ContextPage#create', 'url' => '/api/2/contexts/{contextId}/pages', 'verb' => 'POST'], + ['name' => 'ContextPage#show', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'GET'], + ['name' => 'ContextPage#update', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PATCH'], + ['name' => 'ContextPage#destroy', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'DELETE'], + ['name' => 'ContextPage#saveWidgets', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}/widgets', 'verb' => 'PUT'], + ['name' => 'ContextPage#widgetTypes', 'url' => '/api/2/widget-types', 'verb' => 'GET'], ['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'], ['name' => 'Config#getViewConfig', 'url' => '/api/2/config/view/{id}', 'verb' => 'GET'], diff --git a/lib/Controller/ContextPageController.php b/lib/Controller/ContextPageController.php new file mode 100644 index 0000000000..0ed8723f51 --- /dev/null +++ b/lib/Controller/ContextPageController.php @@ -0,0 +1,234 @@ +, array{}>|DataResponse + * + * 200: Pages returned + * 404: Not found + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')] + public function index(int $contextId): DataResponse { + try { + return new DataResponse($this->pageService->findAll($contextId, $this->userId)); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Get one page of an application with its widgets + * + * Content without a position is placed full width below the positioned widgets. + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @return DataResponse|DataResponse + * + * 200: Page returned + * 404: Not found + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')] + public function show(int $contextId, int $pageId): DataResponse { + try { + return new DataResponse($this->pageService->find($contextId, $pageId, $this->userId)); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Create a grid page in an application + * + * @param int $contextId ID of the application + * @param string $title Title of the page + * @param string|null $technicalName Name to address the page by, unique within the application + * @return DataResponse|DataResponse + * + * 200: Page created + * 400: Invalid title or technical name + * 403: No permissions + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + public function create(int $contextId, string $title, ?string $technicalName = null): DataResponse { + try { + return new DataResponse($this->pageService->create($contextId, $title, $technicalName, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Change the title or technical name of a page + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @param string|null $title New title, null keeps it + * @param string|null $technicalName New technical name, null keeps it and an empty string removes it + * @return DataResponse|DataResponse + * + * 200: Page updated + * 400: Invalid title or technical name + * 403: No permissions + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + public function update(int $contextId, int $pageId, ?string $title = null, ?string $technicalName = null): DataResponse { + try { + return new DataResponse($this->pageService->update($contextId, $pageId, $title, $technicalName, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (DoesNotExistException|MultipleObjectsReturnedException $e) { + return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e)); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Delete a grid page with its widgets + * + * The start page of an application cannot be deleted. + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @return DataResponse|DataResponse + * + * 200: Page deleted + * 400: The page is the start page + * 403: No permissions + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + public function destroy(int $contextId, int $pageId): DataResponse { + try { + return new DataResponse($this->pageService->delete($contextId, $pageId, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] Save the widgets of a page and their layout + * + * Replaces every widget of the page. Each widget has a `type`, a `configuration` and + * a `content` following the schemas of its type, and `gridX`, `gridY`, `gridWidth` + * and `gridHeight` on a grid of 12 columns. A data widget names either `nodeRelId`, a + * node of this application, or `node` as `{nodeType, nodeId}`, a table or view that is + * added to the application with read permissions when it is not part of it yet. + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @param list> $widgets The widgets of the page + * @return DataResponse|DataResponse + * + * 200: Widgets saved + * 400: Invalid widget + * 403: No permissions on the application or on a table or view to add + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + public function saveWidgets(int $contextId, int $pageId, array $widgets = []): DataResponse { + try { + return new DataResponse($this->pageService->saveWidgets($contextId, $pageId, $widgets, $this->userId)); + } catch (BadRequestError $e) { + return $this->handleBadRequestError($e); + } catch (PermissionError $e) { + return $this->handlePermissionError($e); + } catch (NotFoundError $e) { + return $this->handleNotFoundError($e); + } catch (Exception $e) { + return $this->handleError($e); + } + } + + /** + * [api v2] The widget types a page can hold + * + * Every type carries two schemas: the configuration of the widget on the page and its content. + * + * @return DataResponse, array{}> + * + * 200: Widget types returned + */ + #[NoAdminRequired] + public function widgetTypes(): DataResponse { + return new DataResponse(array_values($this->gridWidgetTypes->all())); + } +} diff --git a/lib/Db/Page.php b/lib/Db/Page.php index cc77693074..7b7a66ba53 100644 --- a/lib/Db/Page.php +++ b/lib/Db/Page.php @@ -33,6 +33,7 @@ class Page extends Entity implements \JsonSerializable { public function __construct() { $this->addType('id', 'integer'); + $this->addType('context_id', 'integer'); } public static function isValidTechnicalName(string $technicalName): bool { diff --git a/lib/Db/PageMapper.php b/lib/Db/PageMapper.php index b7a1ce428a..cf6deb9e28 100644 --- a/lib/Db/PageMapper.php +++ b/lib/Db/PageMapper.php @@ -7,7 +7,10 @@ namespace OCA\Tables\Db; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; +use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; @@ -19,6 +22,20 @@ public function __construct(IDBConnection $db) { parent::__construct($db, $this->table, Page::class); } + /** + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws Exception + */ + public function findById(int $pageId): Page { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->table) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT))); + + return $this->findEntity($qb); + } + /** * @return int[] */ diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 194aa0a24a..cb4f33d3fe 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -237,6 +237,46 @@ * ownerType: int, * } * + * @psalm-type TablesGridWidgetValue = string|bool|int|null + * + * @psalm-type TablesGridWidgetProperty = array{ + * type: 'string'|'text'|'color'|'boolean'|'enum', + * title: string, + * default: TablesGridWidgetValue, + * options?: list, + * required?: bool, + * } + * + * @psalm-type TablesGridWidgetType = array{ + * type: 'header'|'text'|'data', + * title: string, + * defaultWidth: int, + * defaultHeight: int, + * configuration: array, + * properties: array, + * } + * + * @psalm-type TablesPageContent = array{ + * id: int, + * order: int, + * node_rel_id: int|null, + * type: string, + * configuration: array, + * content: array, + * grid_x: int, + * grid_y: int, + * grid_width: int, + * grid_height: int, + * } + * + * @psalm-type TablesPage = array{ + * id: int, + * page_type: string, + * title: string|null, + * technical_name: string|null, + * content: list, + * } + * * @psalm-type TablesContextNavigation = array{ * id: int, * shareId: int, diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php index cf5e672160..996eb1c9ca 100644 --- a/lib/Service/GridWidgetTypes.php +++ b/lib/Service/GridWidgetTypes.php @@ -9,14 +9,21 @@ namespace OCA\Tables\Service; +use OCA\Tables\Errors\BadRequestError; +use OCA\Tables\ResponseDefinitions; +use OCP\IL10N; + /** - * The widget types a page can hold, with the size, configuration and content - * a widget of each type starts with. + * The widget types a page can hold. Every type has a schema for its configuration + * and one for its content; the frontend renders its forms from them and saved + * widgets are validated against them. * * A data widget shows the node its page content row points at, so it has no * content of its own. * - * @psalm-type TablesGridWidgetValue = string|bool|int|null + * @psalm-import-type TablesGridWidgetValue from ResponseDefinitions + * @psalm-import-type TablesGridWidgetProperty from ResponseDefinitions + * @psalm-import-type TablesGridWidgetType from ResponseDefinitions */ class GridWidgetTypes { public const TYPE_HEADER = 'header'; @@ -25,6 +32,8 @@ class GridWidgetTypes { public const GRID_COLUMNS = 12; + private const HEX_COLOR = '/^#[0-9a-fA-F]{3,6}$/'; + /** * Default width, height, configuration and content of every type. */ @@ -49,6 +58,11 @@ class GridWidgetTypes { ], ]; + public function __construct( + private readonly IL10N $l10n, + ) { + } + /** * The configuration and content a widget of the given type starts with. * An unknown type has none. @@ -61,4 +75,164 @@ public static function defaults(string $type): array { 'content' => self::DEFAULTS[$type]['content'] ?? [], ]; } + + /** + * @return array + */ + public function all(): array { + return [ + self::TYPE_HEADER => $this->type(self::TYPE_HEADER, $this->l10n->t('Header'), [ + 'title' => ['type' => 'string', 'title' => $this->l10n->t('Heading'), 'required' => true], + 'subtitle' => ['type' => 'string', 'title' => $this->l10n->t('Subheading')], + 'textAlign' => ['type' => 'enum', 'title' => $this->l10n->t('Text alignment'), 'options' => [ + ['value' => 'left', 'label' => $this->l10n->t('Start')], + ['value' => 'center', 'label' => $this->l10n->t('Centered')], + ['value' => 'right', 'label' => $this->l10n->t('End')], + ]], + 'backgroundColor' => ['type' => 'color', 'title' => $this->l10n->t('Background color')], + 'textColor' => ['type' => 'color', 'title' => $this->l10n->t('Text color')], + ]), + self::TYPE_TEXT => $this->type(self::TYPE_TEXT, $this->l10n->t('Description'), [ + 'text' => ['type' => 'text', 'title' => $this->l10n->t('Text')], + ]), + self::TYPE_DATA => $this->type(self::TYPE_DATA, $this->l10n->t('Table or view'), []), + ]; + } + + /** + * @return TablesGridWidgetType + * @throws BadRequestError + */ + public function get(string $type): array { + $types = $this->all(); + if (!isset($types[$type])) { + throw new BadRequestError('Unknown widget type "' . $type . '", expected one of: ' . implode(', ', array_keys($types))); + } + return $types[$type]; + } + + /** + * Checks the configuration and content of a widget against the schemas of its type + * and returns them with defaults filled in and unknown properties dropped. + * + * @return array{configuration: array, content: array} + * @throws BadRequestError + */ + public function sanitize(string $type, mixed $configuration, mixed $content): array { + $schema = $this->get($type); + $configuration ??= []; + $content ??= []; + if (!is_array($configuration)) { + throw new BadRequestError('The configuration of a ' . $type . ' widget must be an object.'); + } + if (!is_array($content)) { + throw new BadRequestError('The content of a ' . $type . ' widget must be an object.'); + } + return [ + 'configuration' => $this->sanitizeProperties($type, $schema['configuration'], $configuration), + 'content' => $this->sanitizeProperties($type, $schema['properties'], $content), + ]; + } + + /** + * @param array, required?: bool}> $properties + * @return TablesGridWidgetType + */ + private function type(string $type, string $title, array $properties): array { + $defaults = self::DEFAULTS[$type]; + return [ + 'type' => $type, + 'title' => $title, + 'defaultWidth' => $defaults['width'], + 'defaultHeight' => $defaults['height'], + 'configuration' => $this->withDefaults([ + 'title' => ['type' => 'string', 'title' => $this->l10n->t('Title')], + 'showTitle' => ['type' => 'boolean', 'title' => $this->l10n->t('Show the title')], + ], $defaults['configuration']), + 'properties' => $this->withDefaults($properties, $defaults['content']), + ]; + } + + /** + * @param array, required?: bool}> $properties + * @param array $defaults + * @return array + */ + private function withDefaults(array $properties, array $defaults): array { + $withDefaults = []; + foreach ($properties as $name => $property) { + $withDefaults[$name] = array_merge($property, ['default' => $defaults[$name] ?? null]); + } + return $withDefaults; + } + + /** + * Every property checked against its schema, defaults filled in and unknown properties dropped. + * + * @param array $schema + * @param array $values + * @return array + * @throws BadRequestError + */ + private function sanitizeProperties(string $type, array $schema, array $values): array { + $sanitized = []; + foreach ($schema as $name => $property) { + $value = array_key_exists($name, $values) ? $values[$name] : $property['default']; + $sanitized[$name] = $this->sanitizeProperty($type . '.' . $name, $property, $value); + } + return $sanitized; + } + + /** + * @param TablesGridWidgetProperty $property + * @return TablesGridWidgetValue + * @throws BadRequestError + */ + private function sanitizeProperty(string $label, array $property, mixed $value): string|bool|null { + return match ($property['type']) { + 'string', 'text' => $this->sanitizeText($label, $value, $property['required'] ?? false), + 'color' => $this->sanitizeColor($label, $value), + 'boolean' => is_bool($value) ? $value : throw new BadRequestError('Widget property ' . $label . ' must be true or false.'), + 'enum' => $this->sanitizeEnum($label, $value, array_column($property['options'] ?? [], 'value')), + default => throw new BadRequestError('Widget property ' . $label . ' has an unknown schema type.'), + }; + } + + /** + * @throws BadRequestError + */ + private function sanitizeText(string $label, mixed $value, bool $isRequired): string { + $value ??= ''; + if (!is_string($value)) { + throw new BadRequestError('Widget property ' . $label . ' must be a string.'); + } + if ($isRequired && trim($value) === '') { + throw new BadRequestError('Widget property ' . $label . ' is required.'); + } + return $value; + } + + /** + * @throws BadRequestError + */ + private function sanitizeColor(string $label, mixed $value): string { + if ($value === null || $value === '') { + return ''; + } + if (!is_string($value) || !preg_match(self::HEX_COLOR, $value)) { + throw new BadRequestError('Widget property ' . $label . ' must be a hex color.'); + } + return $value; + } + + /** + * @param list $allowed + * @throws BadRequestError + */ + private function sanitizeEnum(string $label, mixed $value, array $allowed): string { + if (!is_string($value) || !in_array($value, $allowed, true)) { + throw new BadRequestError('Widget property ' . $label . ' must be one of: ' . implode(', ', $allowed) . '.'); + } + return $value; + } } diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php new file mode 100644 index 0000000000..5851a3c72a --- /dev/null +++ b/lib/Service/PageService.php @@ -0,0 +1,362 @@ + + * @throws NotFoundError + * @throws Exception + */ + public function findAll(int $contextId, string $userId): array { + $context = $this->contextMapper->findById($contextId, $userId); + return array_values(array_map($this->formatPage(...), $context->getPages())); + } + + /** + * @return TablesPage + * @throws NotFoundError + * @throws Exception + */ + public function find(int $contextId, int $pageId, string $userId): array { + $context = $this->contextMapper->findById($contextId, $userId); + return $this->formatPage($this->getPageArray($context, $pageId)); + } + + /** + * @return TablesPage + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function create(int $contextId, string $title, ?string $technicalName, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + + $page = new Page(); + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + $page->setTitle($this->validateTitle($title)); + $page->setTechnicalName($this->validateTechnicalName($context, $technicalName, null)); + $page = $this->pageMapper->insert($page); + + return $this->find($contextId, $page->getId(), $userId); + } + + /** + * Changes the title and technical name of a page. Null keeps the current value, + * an empty technical name removes it. + * + * @return TablesPage + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function update(int $contextId, int $pageId, ?string $title, ?string $technicalName, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + $this->getPageArray($context, $pageId); + + $page = $this->pageMapper->findById($pageId); + if ($title !== null) { + $page->setTitle($this->validateTitle($title)); + } + if ($technicalName !== null) { + $page->setTechnicalName($technicalName === '' ? null : $this->validateTechnicalName($context, $technicalName, $pageId)); + } + $this->pageMapper->update($page); + + return $this->find($contextId, $pageId, $userId); + } + + /** + * Deletes a grid page and its widgets. The start page cannot be deleted. + * + * @return TablesPage the deleted page + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function delete(int $contextId, int $pageId, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + $page = $this->getPageArray($context, $pageId); + if ($page['page_type'] === Page::TYPE_STARTPAGE) { + throw new BadRequestError('The start page of an application cannot be deleted.'); + } + + $this->atomic(function () use ($pageId): void { + $this->pageContentMapper->deleteByPageId($pageId); + $this->pageMapper->deleteByPageId($pageId); + }, $this->dbc); + + return $this->formatPage($page); + } + + /** + * Replaces the widgets of a page and their layout. + * + * Every widget needs a type and a grid position. A data widget names a node of the + * application by `nodeRelId`, or a table or view by `node: {nodeType, nodeId}`, which + * is added to the application first when it is not part of it yet. + * + * @param list> $widgets + * @return TablesPage + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function saveWidgets(int $contextId, int $pageId, array $widgets, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + $this->getPageArray($context, $pageId); + + $nodeKeys = []; + foreach ($context->getNodes() as $relId => $node) { + $nodeKeys[$this->nodeKey($node['node_type'], $node['node_id'])] = (int)$relId; + } + + $rows = []; + $nodesToAdd = []; + foreach (array_values($widgets) as $index => $widget) { + if (!is_array($widget)) { + throw new BadRequestError('Widget ' . $index . ' must be an object.'); + } + $type = $widget['type'] ?? null; + if (!is_string($type)) { + throw new BadRequestError('Widget ' . $index . ' needs a type.'); + } + $row = $this->gridWidgetTypes->sanitize($type, $widget['configuration'] ?? null, $widget['content'] ?? null); + $row['type'] = $type; + $row['position'] = $this->validatePosition($index, $widget); + $row['nodeKey'] = null; + + if ($type === GridWidgetTypes::TYPE_DATA) { + [$nodeKey, $newNode] = $this->resolveDataNode($index, $widget, $context, $nodeKeys); + $row['nodeKey'] = $nodeKey; + if ($newNode !== null) { + $nodesToAdd[$nodeKey] = $newNode; + } + } + $rows[] = $row; + } + + foreach ($nodesToAdd as $node) { + if (!$this->permissionsService->canManageNodeById($node['nodeType'], $node['nodeId'], $userId)) { + throw new PermissionError(sprintf('User %s cannot manage node %d (type %d)', $userId, $node['nodeId'], $node['nodeType'])); + } + } + + $orders = PageContentHelper::orderFromPositions(array_map(static fn (array $row): array => $row['position'], $rows)); + + $this->atomic(function () use ($context, $pageId, $rows, $nodesToAdd, $orders, $nodeKeys): void { + foreach ($nodesToAdd as $key => $node) { + $relation = $this->contextService->addNodeToContext($context, $node['nodeId'], $node['nodeType'], Application::PERMISSION_READ); + $nodeKeys[$key] = $relation->getId(); + } + + $this->pageContentMapper->deleteByPageId($pageId); + foreach ($rows as $index => $row) { + $content = new PageContent(); + $content->setPageId($pageId); + $content->setType($row['type']); + $content->setNodeRelId($row['nodeKey'] !== null ? $nodeKeys[$row['nodeKey']] : null); + $content->setConfiguration(json_encode(['configuration' => $row['configuration'], 'content' => $row['content']], JSON_THROW_ON_ERROR)); + $content->setGridX($row['position']['grid_x']); + $content->setGridY($row['position']['grid_y']); + $content->setGridWidth($row['position']['grid_width']); + $content->setGridHeight($row['position']['grid_height']); + $content->setOrder($orders[$index]); + $this->pageContentMapper->insert($content); + } + }, $this->dbc); + + return $this->find($contextId, $pageId, $userId); + } + + /** + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + private function getManageableContext(int $contextId, string $userId): Context { + $context = $this->contextMapper->findById($contextId, $userId); + if (!$this->permissionsService->canManageContextById($contextId, $userId)) { + throw new PermissionError(sprintf('User %s cannot manage application %d', $userId, $contextId)); + } + return $context; + } + + /** + * @return array + * @throws NotFoundError + */ + private function getPageArray(Context $context, int $pageId): array { + $pages = $context->getPages(); + if (!isset($pages[$pageId])) { + throw new NotFoundError('Page ' . $pageId . ' does not belong to application ' . $context->getId()); + } + return $pages[$pageId]; + } + + /** + * @param array $page + * @return TablesPage + */ + private function formatPage(array $page): array { + $content = array_values(PageContentHelper::readContent($page['content'] ?? [])); + usort($content, static fn (array $a, array $b): int => [$a['order'], $a['id']] <=> [$b['order'], $b['id']]); + /** @var list $content */ + $content = array_map(static fn (array $entry): array => [ + 'id' => $entry['id'], + 'order' => $entry['order'], + 'node_rel_id' => $entry['node_rel_id'], + 'type' => $entry['type'], + 'configuration' => $entry['configuration'], + 'content' => $entry['content'], + 'grid_x' => $entry['grid_x'], + 'grid_y' => $entry['grid_y'], + 'grid_width' => $entry['grid_width'], + 'grid_height' => $entry['grid_height'], + ], $content); + + return [ + 'id' => (int)$page['id'], + 'page_type' => (string)$page['page_type'], + 'title' => $page['title'] ?? null, + 'technical_name' => $page['technical_name'] ?? null, + 'content' => $content, + ]; + } + + /** + * @throws BadRequestError + */ + private function validateTitle(string $title): string { + $title = trim($title); + if ($title === '' || mb_strlen($title) > self::TITLE_MAX_LENGTH) { + throw new BadRequestError('A page title needs 1 to ' . self::TITLE_MAX_LENGTH . ' characters.'); + } + return $title; + } + + /** + * A technical name is optional, and unique within its application. + * + * @throws BadRequestError + */ + private function validateTechnicalName(Context $context, ?string $technicalName, ?int $pageId): ?string { + if ($technicalName === null) { + return null; + } + if (!Page::isValidTechnicalName($technicalName)) { + throw new BadRequestError('A technical name starts with a lowercase letter and holds only lowercase letters, digits and underscores.'); + } + foreach ($context->getPages() as $id => $page) { + if ((int)$id !== $pageId && ($page['technical_name'] ?? null) === $technicalName) { + throw new BadRequestError('Another page of this application already uses the technical name ' . $technicalName . '.'); + } + } + return $technicalName; + } + + /** + * @param array $widget + * @return array{grid_x: int, grid_y: int, grid_width: int, grid_height: int} + * @throws BadRequestError + */ + private function validatePosition(int $index, array $widget): array { + $position = []; + foreach (['gridX' => 'grid_x', 'gridY' => 'grid_y', 'gridWidth' => 'grid_width', 'gridHeight' => 'grid_height'] as $name => $key) { + $value = $widget[$name] ?? null; + $minimum = $key === 'grid_width' || $key === 'grid_height' ? 1 : 0; + if (!is_int($value) || $value < $minimum) { + throw new BadRequestError('Widget ' . $index . ' needs ' . $name . ' as an integer of at least ' . $minimum . '.'); + } + $position[$key] = $value; + } + if ($position['grid_x'] + $position['grid_width'] > GridWidgetTypes::GRID_COLUMNS) { + throw new BadRequestError('Widget ' . $index . ' does not fit in ' . GridWidgetTypes::GRID_COLUMNS . ' columns.'); + } + return $position; + } + + /** + * The node of a data widget: a node relation of this application, or a table or view + * that becomes one. + * + * @param array $widget + * @param array $nodeKeys relation ids of the application's nodes by node key + * @return array{0: string, 1: array{nodeType: int, nodeId: int}|null} the node key, and the node to add if it is new + * @throws BadRequestError + */ + private function resolveDataNode(int $index, array $widget, Context $context, array $nodeKeys): array { + $nodeRelId = $widget['nodeRelId'] ?? null; + if ($nodeRelId !== null) { + $node = is_int($nodeRelId) ? ($context->getNodes()[$nodeRelId] ?? null) : null; + if ($node === null) { + throw new BadRequestError('Widget ' . $index . ' shows a node that is not part of this application.'); + } + return [$this->nodeKey($node['node_type'], $node['node_id']), null]; + } + + $node = $widget['node'] ?? null; + $nodeType = is_array($node) ? ($node['nodeType'] ?? null) : null; + $nodeId = is_array($node) ? ($node['nodeId'] ?? null) : null; + if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_VIEW], true) || !is_int($nodeId) || $nodeId <= 0) { + throw new BadRequestError('Widget ' . $index . ' needs a nodeRelId of this application or a node of type table or view.'); + } + $key = $this->nodeKey($nodeType, $nodeId); + return [$key, isset($nodeKeys[$key]) ? null : ['nodeType' => $nodeType, 'nodeId' => $nodeId]]; + } + + private function nodeKey(int $nodeType, int $nodeId): string { + return sprintf('t%di%d', $nodeType, $nodeId); + } +} diff --git a/openapi.json b/openapi.json index c140ed6423..53d1c5186e 100644 --- a/openapi.json +++ b/openapi.json @@ -330,6 +330,112 @@ } } }, + "GridWidgetProperty": { + "type": "object", + "required": [ + "type", + "title", + "default" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "text", + "color", + "boolean", + "enum" + ] + }, + "title": { + "type": "string" + }, + "default": { + "$ref": "#/components/schemas/GridWidgetValue" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "required": { + "type": "boolean" + } + } + }, + "GridWidgetType": { + "type": "object", + "required": [ + "type", + "title", + "defaultWidth", + "defaultHeight", + "configuration", + "properties" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "header", + "text", + "data" + ] + }, + "title": { + "type": "string" + }, + "defaultWidth": { + "type": "integer", + "format": "int64" + }, + "defaultHeight": { + "type": "integer", + "format": "int64" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetProperty" + } + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetProperty" + } + } + } + }, + "GridWidgetValue": { + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + } + ] + }, "ImportState": { "type": "object", "required": [ @@ -451,6 +557,100 @@ } } }, + "Page": { + "type": "object", + "required": [ + "id", + "page_type", + "title", + "technical_name", + "content" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "page_type": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "technical_name": { + "type": "string", + "nullable": true + }, + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PageContent" + } + } + } + }, + "PageContent": { + "type": "object", + "required": [ + "id", + "order", + "node_rel_id", + "type", + "configuration", + "content", + "grid_x", + "grid_y", + "grid_width", + "grid_height" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "order": { + "type": "integer", + "format": "int64" + }, + "node_rel_id": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "type": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetValue" + } + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetValue" + } + }, + "grid_x": { + "type": "integer", + "format": "int64" + }, + "grid_y": { + "type": "integer", + "format": "int64" + }, + "grid_width": { + "type": "integer", + "format": "int64" + }, + "grid_height": { + "type": "integer", + "format": "int64" + } + } + }, "PublicColumn": { "type": "object", "required": [ @@ -14287,6 +14487,1563 @@ } } } + }, + "get": { + "operationId": "context_page-show", + "summary": "[api v2] Get one page of an application with its widgets", + "description": "Content without a position is placed full width below the positioned widgets.", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Page returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "patch": { + "operationId": "context_page-update", + "summary": "[api v2] Change the title or technical name of a page", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "nullable": true, + "default": null, + "description": "New title, null keeps it" + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "New technical name, null keeps it and an empty string removes it" + } + } + } + } + } + }, + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Page updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid title or technical name", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "context_page-destroy", + "summary": "[api v2] Delete a grid page with its widgets", + "description": "The start page of an application cannot be deleted.", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Page deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + }, + "400": { + "description": "The page is the start page", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages": { + "get": { + "operationId": "context_page-index", + "summary": "[api v2] Get the pages of an application with their widgets", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Pages returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "context_page-create", + "summary": "[api v2] Create a grid page in an application", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Title of the page" + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "Name to address the page by, unique within the application" + } + } + } + } + } + }, + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Page created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid title or technical name", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages/{pageId}/widgets": { + "put": { + "operationId": "context_page-save-widgets", + "summary": "[api v2] Save the widgets of a page and their layout", + "description": "Replaces every widget of the page. Each widget has a `type`, a `configuration` and a `content` following the schemas of its type, and `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns. A data widget names either `nodeRelId`, a node of this application, or `node` as `{nodeType, nodeId}`, a table or view that is added to the application with read permissions when it is not part of it yet.", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "widgets": { + "type": "array", + "default": [], + "description": "The widgets of the page", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Widgets saved", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid widget", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions on the application or on a table or view to add", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/widget-types": { + "get": { + "operationId": "context_page-widget-types", + "summary": "[api v2] The widget types a page can hold", + "description": "Every type carries two schemas: the configuration of the widget on the page and its content.", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Widget types returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GridWidgetType" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } } }, "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2c3d2b29ab..941cdffe3c 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -900,10 +900,77 @@ export type paths = { readonly path?: never; readonly cookie?: never; }; - readonly get?: never; + /** + * [api v2] Get one page of an application with its widgets + * @description Content without a position is placed full width below the positioned widgets. + */ + readonly get: operations["context_page-show"]; /** [api v2] Update the order on a page of a context */ readonly put: operations["context-update-content-order"]; readonly post?: never; + /** + * [api v2] Delete a grid page with its widgets + * @description The start page of an application cannot be deleted. + */ + readonly delete: operations["context_page-destroy"]; + readonly options?: never; + readonly head?: never; + /** [api v2] Change the title or technical name of a page */ + readonly patch: operations["context_page-update"]; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** [api v2] Get the pages of an application with their widgets */ + readonly get: operations["context_page-index"]; + readonly put?: never; + /** [api v2] Create a grid page in an application */ + readonly post: operations["context_page-create"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages/{pageId}/widgets": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + /** + * [api v2] Save the widgets of a page and their layout + * @description Replaces every widget of the page. Each widget has a `type`, a `configuration` and a `content` following the schemas of its type, and `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns. A data widget names either `nodeRelId`, a node of this application, or `node` as `{nodeType, nodeId}`, a table or view that is added to the application with read permissions when it is not part of it yet. + */ + readonly put: operations["context_page-save-widgets"]; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/widget-types": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** + * [api v2] The widget types a page can hold + * @description Every type carries two schemas: the configuration of the widget on the page and its content. + */ + readonly get: operations["context_page-widget-types"]; + readonly put?: never; + readonly post?: never; readonly delete?: never; readonly options?: never; readonly head?: never; @@ -1162,6 +1229,33 @@ export type components = { readonly displayMode: number; readonly userId: string; }; + readonly GridWidgetProperty: { + /** @enum {string} */ + readonly type: "string" | "text" | "color" | "boolean" | "enum"; + readonly title: string; + readonly default: components["schemas"]["GridWidgetValue"]; + readonly options?: readonly { + readonly value: string; + readonly label: string; + }[]; + readonly required?: boolean; + }; + readonly GridWidgetType: { + /** @enum {string} */ + readonly type: "header" | "text" | "data"; + readonly title: string; + /** Format: int64 */ + readonly defaultWidth: number; + /** Format: int64 */ + readonly defaultHeight: number; + readonly configuration: { + readonly [key: string]: components["schemas"]["GridWidgetProperty"]; + }; + readonly properties: { + readonly [key: string]: components["schemas"]["GridWidgetProperty"]; + }; + }; + readonly GridWidgetValue: (string | boolean | number) | null; readonly ImportState: { /** Format: int64 */ readonly found_columns_count: number; @@ -1198,6 +1292,37 @@ export type components = { readonly totalitems?: string; readonly itemsperpage?: string; }; + readonly Page: { + /** Format: int64 */ + readonly id: number; + readonly page_type: string; + readonly title: string | null; + readonly technical_name: string | null; + readonly content: readonly components["schemas"]["PageContent"][]; + }; + readonly PageContent: { + /** Format: int64 */ + readonly id: number; + /** Format: int64 */ + readonly order: number; + /** Format: int64 */ + readonly node_rel_id: number | null; + readonly type: string; + readonly configuration: { + readonly [key: string]: components["schemas"]["GridWidgetValue"]; + }; + readonly content: { + readonly [key: string]: components["schemas"]["GridWidgetValue"]; + }; + /** Format: int64 */ + readonly grid_x: number; + /** Format: int64 */ + readonly grid_y: number; + /** Format: int64 */ + readonly grid_width: number; + /** Format: int64 */ + readonly grid_height: number; + }; readonly PublicColumn: { /** Format: int64 */ readonly id: number; @@ -7746,6 +7871,84 @@ export interface operations { }; }; }; + readonly "context_page-show": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Page returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Page"]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; readonly "context-update-content-order": { readonly parameters: { readonly query?: never; @@ -7868,6 +8071,601 @@ export interface operations { }; }; }; + readonly "context_page-destroy": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Page deleted */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Page"]; + }; + }; + }; + }; + /** @description The page is the start page */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context_page-update": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: { + readonly content: { + readonly "application/json": { + /** + * @description New title, null keeps it + * @default null + */ + readonly title?: string | null; + /** + * @description New technical name, null keeps it and an empty string removes it + * @default null + */ + readonly technicalName?: string | null; + }; + }; + }; + readonly responses: { + /** @description Page updated */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Page"]; + }; + }; + }; + }; + /** @description Invalid title or technical name */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context_page-index": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the application */ + readonly contextId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Pages returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: readonly components["schemas"]["Page"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context_page-create": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the application */ + readonly contextId: number; + }; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": { + /** @description Title of the page */ + readonly title: string; + /** + * @description Name to address the page by, unique within the application + * @default null + */ + readonly technicalName?: string | null; + }; + }; + }; + readonly responses: { + /** @description Page created */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Page"]; + }; + }; + }; + }; + /** @description Invalid title or technical name */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context_page-save-widgets": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path: { + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: { + readonly content: { + readonly "application/json": { + /** + * @description The widgets of the page + * @default [] + */ + readonly widgets?: readonly { + readonly [key: string]: Record; + }[]; + }; + }; + }; + readonly responses: { + /** @description Widgets saved */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["Page"]; + }; + }; + }; + }; + /** @description Invalid widget */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions on the application or on a table or view to add */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context_page-widget-types": { + readonly parameters: { + readonly query?: never; + readonly header: { + /** @description Required to be true for the API request to pass */ + readonly "OCS-APIRequest": boolean; + }; + readonly path?: never; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Widget types returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: readonly components["schemas"]["GridWidgetType"][]; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + }; + }; readonly "config-get-table-config": { readonly parameters: { readonly query?: never; diff --git a/tests/unit/Controller/ContextPageControllerTest.php b/tests/unit/Controller/ContextPageControllerTest.php new file mode 100644 index 0000000000..4b3db9b323 --- /dev/null +++ b/tests/unit/Controller/ContextPageControllerTest.php @@ -0,0 +1,134 @@ +id = 7; + $context->setNodes([20 => ['id' => 20, 'node_id' => 3, 'node_type' => 0, 'permissions' => 1]]); + $context->setPages([ + 1 => ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'technical_name' => null, 'content' => []], + 2 => ['id' => 2, 'page_type' => Page::TYPE_GRID, 'title' => 'Sales', 'technical_name' => 'sales', 'content' => []], + ]); + $contextMapper = $this->createMock(ContextMapper::class); + $contextMapper->method('findById')->willReturnCallback(static function (int $contextId) use ($context): Context { + if ($contextId !== 7) { + throw new NotFoundError('Context does not exist'); + } + return $context; + }); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->pageContentMapper = $this->createMock(PageContentMapper::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $this->gridWidgetTypes = new GridWidgetTypes($l10n); + + $this->controller = new ContextPageController( + $this->createMock(IRequest::class), + $this->createMock(LoggerInterface::class), + $l10n, + 'bob', + new PageService( + $contextMapper, + $this->createMock(PageMapper::class), + $this->pageContentMapper, + $this->createMock(ContextService::class), + $this->permissionsService, + $this->gridWidgetTypes, + $this->createMock(IDBConnection::class), + ), + $this->gridWidgetTypes, + ); + } + + public function testReadsReturnThePages(): void { + $index = $this->controller->index(7); + $show = $this->controller->show(7, 2); + + $this->assertSame(Http::STATUS_OK, $index->getStatus()); + $this->assertSame([1, 2], array_column($index->getData(), 'id')); + $this->assertSame('sales', $show->getData()['technical_name']); + } + + public function testUnknownApplicationOrPageIsNotFound(): void { + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->index(8)->getStatus()); + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->show(7, 9)->getStatus()); + } + + public function testWritesOfANonManagerAreForbidden(): void { + $this->permissionsService->method('canManageContextById')->with(7, 'bob')->willReturn(false); + $this->pageContentMapper->expects($this->never())->method('insert'); + + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller->create(7, 'Overview')->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller->update(7, 2, 'Renamed')->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller->destroy(7, 2)->getStatus()); + $this->assertSame(Http::STATUS_FORBIDDEN, $this->controller->saveWidgets(7, 2, [])->getStatus()); + } + + public function testAForeignNodeRelationIsABadRequest(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageContentMapper->expects($this->never())->method('insert'); + + $response = $this->controller->saveWidgets(7, 2, [ + ['type' => 'data', 'nodeRelId' => 99, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 5], + ]); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testAddingANodeTheManagerCannotManageIsForbidden(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->permissionsService->method('canManageNodeById')->willReturn(false); + + $response = $this->controller->saveWidgets(7, 2, [ + ['type' => 'data', 'node' => ['nodeType' => 0, 'nodeId' => 4], 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 5], + ]); + + $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); + } + + public function testDeletingTheStartPageIsABadRequest(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->destroy(7, 1)->getStatus()); + } + + public function testWidgetTypesListsEveryType(): void { + $types = $this->controller->widgetTypes()->getData(); + + $this->assertSame(['header', 'text', 'data'], array_column($types, 'type')); + } +} diff --git a/tests/unit/Db/ContextMapperTest.php b/tests/unit/Db/ContextMapperTest.php index 37323730ca..70f9349cca 100644 --- a/tests/unit/Db/ContextMapperTest.php +++ b/tests/unit/Db/ContextMapperTest.php @@ -175,6 +175,15 @@ public function testFindAllContainingNodeSkipsContextsWithoutIt(): void { $this->assertSame([], $this->mapper->findAllContainingNode(Application::NODE_TYPE_TABLE, 62, self::OWNER)); } + public function testPageMapperFindsAPageById(): void { + $context = $this->createContext(); + $page = $this->addPage($context, Page::TYPE_GRID, 'Overview', 'overview'); + + $found = $this->pageMapper->findById($page->getId()); + + $this->assertSame([$context->getId(), Page::TYPE_GRID, 'Overview', 'overview'], [$found->getContextId(), $found->getPageType(), $found->getTitle(), $found->getTechnicalName()]); + } + private function createContext(): Context { $context = new Context(); $context->setName('Mapper test'); diff --git a/tests/unit/Service/GridWidgetTypesTest.php b/tests/unit/Service/GridWidgetTypesTest.php new file mode 100644 index 0000000000..ba88cc12e8 --- /dev/null +++ b/tests/unit/Service/GridWidgetTypesTest.php @@ -0,0 +1,93 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => '[' . $text . ']'); + $this->types = new GridWidgetTypes($l10n); + } + + public function testEveryTypeDescribesItsSizeAndSchemas(): void { + $all = $this->types->all(); + + $this->assertSame(['header', 'text', 'data'], array_keys($all)); + foreach ($all as $name => $type) { + $this->assertSame($name, $type['type']); + $this->assertSame(['title', 'showTitle'], array_keys($type['configuration'])); + $this->assertSame(GridWidgetTypes::defaults($name)['configuration'], array_map(static fn (array $property) => $property['default'], $type['configuration'])); + $this->assertSame(GridWidgetTypes::defaults($name)['content'], array_map(static fn (array $property) => $property['default'], $type['properties'])); + } + $this->assertSame([12, 5], [$all['data']['defaultWidth'], $all['data']['defaultHeight']]); + $this->assertSame([], $all['data']['properties']); + } + + public function testTitlesAreTranslated(): void { + $header = $this->types->get('header'); + + $this->assertSame('[Header]', $header['title']); + $this->assertSame('[Heading]', $header['properties']['title']['title']); + $this->assertSame('[Centered]', $header['properties']['textAlign']['options'][1]['label']); + } + + public function testUnknownTypeIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->types->get('chart'); + } + + public function testUnknownTypeHasNoDefaults(): void { + $this->assertSame(['configuration' => [], 'content' => []], GridWidgetTypes::defaults('chart')); + } + + public function testSanitizeFillsDefaultsAndDropsUnknownProperties(): void { + $this->assertSame([ + 'configuration' => ['title' => 'Intro', 'showTitle' => true], + 'content' => ['title' => 'Welcome', 'subtitle' => '', 'textAlign' => 'center', 'backgroundColor' => '#fff', 'textColor' => ''], + ], $this->types->sanitize( + 'header', + ['title' => 'Intro', 'showTitle' => true, 'extra' => 1], + ['title' => 'Welcome', 'textAlign' => 'center', 'backgroundColor' => '#fff', 'script' => ''], + )); + $this->assertSame( + ['configuration' => ['title' => '', 'showTitle' => false], 'content' => []], + $this->types->sanitize('data', null, ['target' => ['type' => 'table', 'id' => 1]]), + ); + } + + public static function invalidWidgets(): array { + return [ + 'configuration not an object' => ['text', 'x', [], 'configuration of a text widget'], + 'content not an object' => ['text', [], 5, 'content of a text widget'], + 'required heading missing' => ['header', [], ['title' => ' '], 'header.title is required'], + 'text not a string' => ['text', [], ['text' => 3], 'text.text must be a string'], + 'show title not a boolean' => ['text', ['showTitle' => 'yes'], [], 'text.showTitle must be true or false'], + 'color not hex' => ['header', [], ['title' => 'A', 'textColor' => 'red'], 'header.textColor must be a hex color'], + 'alignment unknown' => ['header', [], ['title' => 'A', 'textAlign' => 'justify'], 'header.textAlign must be one of'], + ]; + } + + /** + * @dataProvider invalidWidgets + */ + public function testSanitizeRejectsInvalidInput(string $type, mixed $configuration, mixed $content, string $message): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->types->sanitize($type, $configuration, $content); + } +} diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php new file mode 100644 index 0000000000..deb97b52f3 --- /dev/null +++ b/tests/unit/Service/PageServiceTest.php @@ -0,0 +1,285 @@ +contextMapper = $this->createMock(ContextMapper::class); + $this->pageMapper = $this->createMock(PageMapper::class); + $this->pageContentMapper = $this->createMock(PageContentMapper::class); + $this->contextService = $this->createMock(ContextService::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $this->service = new PageService( + $this->contextMapper, + $this->pageMapper, + $this->pageContentMapper, + $this->contextService, + $this->permissionsService, + new GridWidgetTypes($l10n), + $this->createMock(IDBConnection::class), + ); + + $this->context = new Context(); + $this->context->id = 7; + $this->context->setNodes([ + 20 => ['id' => 20, 'node_id' => 3, 'node_type' => Application::NODE_TYPE_TABLE, 'permissions' => 1], + ]); + $this->context->setPages([ + 1 => ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'technical_name' => null, 'content' => [ + 11 => ['id' => 11, 'order' => 10, 'node_rel_id' => 20, 'type' => 'data', 'configuration' => null, 'grid_x' => null, 'grid_y' => null, 'grid_width' => null, 'grid_height' => null], + ]], + 2 => ['id' => 2, 'page_type' => Page::TYPE_GRID, 'title' => 'Sales', 'technical_name' => 'sales', 'content' => []], + ]); + $this->contextMapper->method('findById')->willReturnCallback(function (int $contextId, ?string $userId): Context { + if ($contextId !== 7 || $userId !== self::USER) { + throw new NotFoundError('Context does not exist'); + } + return $this->context; + }); + $this->pageContentMapper->method('insert')->willReturnCallback(function (PageContent $content): PageContent { + $this->inserted[] = $content; + return $content; + }); + } + + public function testFindReadsExistingContentAsDataWidgets(): void { + $page = $this->service->find(7, 1, self::USER); + + $this->assertSame(Page::TYPE_STARTPAGE, $page['page_type']); + $this->assertSame([[ + 'id' => 11, 'order' => 10, 'node_rel_id' => 20, 'type' => 'data', + 'configuration' => ['title' => '', 'showTitle' => false], 'content' => [], + 'grid_x' => 0, 'grid_y' => 0, 'grid_width' => 12, 'grid_height' => 5, + ]], $page['content']); + } + + public function testFindAllListsEveryPage(): void { + $this->assertSame([1, 2], array_column($this->service->findAll(7, self::USER), 'id')); + } + + public function testFindRejectsAPageOfAnotherApplication(): void { + $this->expectException(NotFoundError::class); + $this->service->find(7, 99, self::USER); + } + + public function testCreateAddsAGridPage(): void { + $this->permissionsService->method('canManageContextById')->with(7, self::USER)->willReturn(true); + $this->pageMapper->expects($this->once())->method('insert')->willReturnCallback(function (Page $page): Page { + $this->assertSame(Page::TYPE_GRID, $page->getPageType()); + $this->assertSame('Overview', $page->getTitle()); + $this->assertSame('overview', $page->getTechnicalName()); + $page->id = 3; + $pages = $this->context->getPages(); + $pages[3] = ['id' => 3, 'page_type' => Page::TYPE_GRID, 'title' => 'Overview', 'technical_name' => 'overview', 'content' => []]; + $this->context->setPages($pages); + return $page; + }); + + $page = $this->service->create(7, ' Overview ', 'overview', self::USER); + + $this->assertSame(3, $page['id']); + } + + public static function invalidPageInput(): array { + return [ + 'empty title' => [' ', null], + 'title too long' => [str_repeat('a', 201), null], + 'technical name with capitals' => ['Ok', 'Overview'], + 'technical name starting with a digit' => ['Ok', '1st'], + 'technical name taken' => ['Ok', 'sales'], + ]; + } + + /** + * @dataProvider invalidPageInput + */ + public function testCreateRejectsInvalidInput(string $title, ?string $technicalName): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('insert'); + + $this->expectException(BadRequestError::class); + $this->service->create(7, $title, $technicalName, self::USER); + } + + public function testNonManagerCannotWrite(): void { + $this->permissionsService->method('canManageContextById')->willReturn(false); + $this->pageMapper->expects($this->never())->method('insert'); + $this->pageMapper->expects($this->never())->method('update'); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + $this->pageContentMapper->expects($this->never())->method('deleteByPageId'); + + foreach ([ + fn () => $this->service->create(7, 'Overview', null, self::USER), + fn () => $this->service->update(7, 2, 'Renamed', null, self::USER), + fn () => $this->service->delete(7, 2, self::USER), + fn () => $this->service->saveWidgets(7, 2, [], self::USER), + ] as $write) { + try { + $write(); + $this->fail('A write without manage permission went through'); + } catch (PermissionError) { + } + } + $this->assertSame([], $this->inserted); + } + + public function testUpdateRenamesAndKeepsItsOwnTechnicalName(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = new Page(); + $stored->id = 2; + $stored->setTechnicalName('sales'); + $this->pageMapper->method('findById')->with(2)->willReturn($stored); + $this->pageMapper->expects($this->once())->method('update')->with($stored)->willReturnArgument(0); + + $this->service->update(7, 2, 'Revenue', 'sales', self::USER); + + $this->assertSame('Revenue', $stored->getTitle()); + $this->assertSame('sales', $stored->getTechnicalName()); + } + + public function testUpdateClearsTheTechnicalNameWithAnEmptyString(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = new Page(); + $stored->id = 2; + $stored->setTitle('Sales'); + $stored->setTechnicalName('sales'); + $this->pageMapper->method('findById')->willReturn($stored); + $this->pageMapper->method('update')->willReturnArgument(0); + + $this->service->update(7, 2, null, '', self::USER); + + $this->assertSame('Sales', $stored->getTitle()); + $this->assertNull($stored->getTechnicalName()); + } + + public function testStartPageCannotBeDeleted(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(BadRequestError::class); + $this->service->delete(7, 1, self::USER); + } + + public function testDeleteRemovesAGridPageAndItsContent(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageContentMapper->expects($this->once())->method('deleteByPageId')->with(2); + $this->pageMapper->expects($this->once())->method('deleteByPageId')->with(2); + + $this->assertSame(2, $this->service->delete(7, 2, self::USER)['id']); + } + + public function testSaveWidgetsReplacesContentAndOrdersByPosition(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->permissionsService->expects($this->once())->method('canManageNodeById') + ->with(Application::NODE_TYPE_VIEW, 9, self::USER)->willReturn(true); + $this->contextService->expects($this->once())->method('addNodeToContext') + ->with($this->context, 9, Application::NODE_TYPE_VIEW, Application::PERMISSION_READ) + ->willReturnCallback(static function (): ContextNodeRelation { + $relation = new ContextNodeRelation(); + $relation->id = 21; + return $relation; + }); + $this->pageContentMapper->expects($this->once())->method('deleteByPageId')->with(1); + + $this->service->saveWidgets(7, 1, [ + ['type' => 'data', 'node' => ['nodeType' => Application::NODE_TYPE_VIEW, 'nodeId' => 9], 'gridX' => 6, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], + ['type' => 'header', 'content' => ['title' => 'Welcome'], 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], + ['type' => 'data', 'nodeRelId' => 20, 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], + ['type' => 'data', 'node' => ['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 3], 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5], + ], self::USER); + + $this->assertCount(4, $this->inserted); + $this->assertSame([21, null, 20, 20], array_map(static fn (PageContent $content) => $content->getNodeRelId(), $this->inserted)); + $this->assertSame([30, 10, 20, 40], array_map(static fn (PageContent $content) => $content->getOrder(), $this->inserted)); + $this->assertSame(['data', 'header', 'data', 'data'], array_map(static fn (PageContent $content) => $content->getType(), $this->inserted)); + $this->assertSame([6, 2, 6, 5], [$this->inserted[0]->getGridX(), $this->inserted[0]->getGridY(), $this->inserted[0]->getGridWidth(), $this->inserted[0]->getGridHeight()]); + $this->assertSame( + ['configuration' => ['title' => '', 'showTitle' => false], 'content' => ['title' => 'Welcome', 'subtitle' => '', 'textAlign' => 'left', 'backgroundColor' => '', 'textColor' => '']], + json_decode($this->inserted[1]->getConfiguration(), true), + ); + } + + public function testSaveWidgetsRefusesANodeTheUserCannotManage(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->permissionsService->method('canManageNodeById')->willReturn(false); + $this->contextService->expects($this->never())->method('addNodeToContext'); + $this->pageContentMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(PermissionError::class); + $this->service->saveWidgets(7, 2, [ + ['type' => 'data', 'node' => ['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 4], 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 5], + ], self::USER); + } + + public static function invalidWidgets(): array { + $position = ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 5]; + return [ + 'not an object' => ['header', 'Widget 0 must be an object'], + 'no type' => [$position, 'Widget 0 needs a type'], + 'unknown type' => [['type' => 'chart'] + $position, 'Unknown widget type'], + 'foreign node relation' => [['type' => 'data', 'nodeRelId' => 999] + $position, 'not part of this application'], + 'node relation as string' => [['type' => 'data', 'nodeRelId' => '20'] + $position, 'not part of this application'], + 'data without a node' => [['type' => 'data'] + $position, 'needs a nodeRelId'], + 'node of an unknown type' => [['type' => 'data', 'node' => ['nodeType' => 2, 'nodeId' => 1]] + $position, 'needs a nodeRelId'], + 'missing position' => [['type' => 'text'], 'needs gridX'], + 'negative position' => [['type' => 'text', 'gridX' => -1] + $position, 'needs gridX'], + 'zero height' => [['type' => 'text', 'gridHeight' => 0] + $position, 'needs gridHeight'], + 'wider than the grid' => [['type' => 'text', 'gridX' => 6] + $position, 'does not fit'], + 'invalid content' => [['type' => 'header', 'content' => ['title' => '']] + $position, 'header.title is required'], + ]; + } + + /** + * @dataProvider invalidWidgets + */ + public function testSaveWidgetsRejectsInvalidWidgets(mixed $widget, string $message): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageContentMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->service->saveWidgets(7, 2, [$widget], self::USER); + } +} From af8dfb3fba5c71b49aa424265050b82f6b0d61e0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 15 Oct 2026 22:56:55 +0200 Subject: [PATCH 2/2] feat(pages): require a technical name when creating or renaming a page A page created without a technical name gets one made from its title, unique within the application. An invalid or taken name is a 400, also when another request takes it first, and a rename can no longer remove it. Every page output carries its technical name. Assisted-by: ClaudeCode:claude-opus-5-5 --- lib/Controller/ContextPageController.php | 4 +- lib/ResponseDefinitions.php | 2 +- lib/Service/PageService.php | 64 ++++++++++---- openapi.json | 7 +- src/types/openapi/openapi.ts | 6 +- .../Controller/ContextPageControllerTest.php | 11 ++- tests/unit/Service/PageServiceTest.php | 87 +++++++++++++++++-- 7 files changed, 149 insertions(+), 32 deletions(-) diff --git a/lib/Controller/ContextPageController.php b/lib/Controller/ContextPageController.php index 0ed8723f51..47b94c6b54 100644 --- a/lib/Controller/ContextPageController.php +++ b/lib/Controller/ContextPageController.php @@ -93,7 +93,7 @@ public function show(int $contextId, int $pageId): DataResponse { * * @param int $contextId ID of the application * @param string $title Title of the page - * @param string|null $technicalName Name to address the page by, unique within the application + * @param string|null $technicalName Name to address the page by, unique within the application; null makes one from the title * @return DataResponse|DataResponse * * 200: Page created @@ -124,7 +124,7 @@ public function create(int $contextId, string $title, ?string $technicalName = n * @param int $contextId ID of the application * @param int $pageId ID of the page * @param string|null $title New title, null keeps it - * @param string|null $technicalName New technical name, null keeps it and an empty string removes it + * @param string|null $technicalName New technical name, unique within the application; null keeps it * @return DataResponse|DataResponse * * 200: Page updated diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cb4f33d3fe..e2017f9501 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -273,7 +273,7 @@ * id: int, * page_type: string, * title: string|null, - * technical_name: string|null, + * technical_name: string, * content: list, * } * diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 5851a3c72a..f092a7a720 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -20,6 +20,7 @@ use OCA\Tables\Errors\NotFoundError; use OCA\Tables\Errors\PermissionError; use OCA\Tables\Helper\PageContentHelper; +use OCA\Tables\Helper\TechnicalNameHelper; use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\TTransactional; use OCP\DB\Exception; @@ -83,15 +84,20 @@ public function create(int $contextId, string $title, ?string $technicalName, st $page->setContextId($contextId); $page->setPageType(Page::TYPE_GRID); $page->setTitle($this->validateTitle($title)); - $page->setTechnicalName($this->validateTechnicalName($context, $technicalName, null)); - $page = $this->pageMapper->insert($page); + $page->setTechnicalName($technicalName === null + ? TechnicalNameHelper::unique(TechnicalNameHelper::fromText($page->getTitle() ?? ''), $this->takenTechnicalNames($context, null)) + : $this->validateTechnicalName($context, $technicalName, null)); + try { + $page = $this->pageMapper->insert($page); + } catch (Exception $e) { + throw $this->technicalNameTaken($e, (string)$page->getTechnicalName()); + } return $this->find($contextId, $page->getId(), $userId); } /** - * Changes the title and technical name of a page. Null keeps the current value, - * an empty technical name removes it. + * Changes the title and technical name of a page. Null keeps the current value. * * @return TablesPage * @throws BadRequestError @@ -108,9 +114,13 @@ public function update(int $contextId, int $pageId, ?string $title, ?string $tec $page->setTitle($this->validateTitle($title)); } if ($technicalName !== null) { - $page->setTechnicalName($technicalName === '' ? null : $this->validateTechnicalName($context, $technicalName, $pageId)); + $page->setTechnicalName($this->validateTechnicalName($context, $technicalName, $pageId)); + } + try { + $this->pageMapper->update($page); + } catch (Exception $e) { + throw $this->technicalNameTaken($e, (string)$page->getTechnicalName()); } - $this->pageMapper->update($page); return $this->find($contextId, $pageId, $userId); } @@ -270,7 +280,7 @@ private function formatPage(array $page): array { 'id' => (int)$page['id'], 'page_type' => (string)$page['page_type'], 'title' => $page['title'] ?? null, - 'technical_name' => $page['technical_name'] ?? null, + 'technical_name' => (string)($page['technical_name'] ?? ''), 'content' => $content, ]; } @@ -287,23 +297,45 @@ private function validateTitle(string $title): string { } /** - * A technical name is optional, and unique within its application. + * A technical name is unique within its application. * * @throws BadRequestError */ - private function validateTechnicalName(Context $context, ?string $technicalName, ?int $pageId): ?string { - if ($technicalName === null) { - return null; - } + private function validateTechnicalName(Context $context, string $technicalName, ?int $pageId): string { if (!Page::isValidTechnicalName($technicalName)) { - throw new BadRequestError('A technical name starts with a lowercase letter and holds only lowercase letters, digits and underscores.'); + throw new BadRequestError('A technical name has 1 to ' . Page::TECHNICAL_NAME_MAX_LENGTH . ' characters, starts with a lowercase letter and holds only lowercase letters, digits and underscores.'); + } + if (isset($this->takenTechnicalNames($context, $pageId)[$technicalName])) { + throw new BadRequestError('Another page of this application already uses the technical name ' . $technicalName . '.'); + } + return $technicalName; + } + + /** + * A write another request beat to the same technical name is a bad request; + * any other database error is passed on. + */ + private function technicalNameTaken(Exception $e, string $technicalName): BadRequestError|Exception { + if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + return new BadRequestError('Another page of this application already uses the technical name ' . $technicalName . '.', 0, $e); } + return $e; + } + + /** + * The technical names of the application's pages, apart from the given page. + * + * @return array + */ + private function takenTechnicalNames(Context $context, ?int $pageId): array { + $taken = []; foreach ($context->getPages() as $id => $page) { - if ((int)$id !== $pageId && ($page['technical_name'] ?? null) === $technicalName) { - throw new BadRequestError('Another page of this application already uses the technical name ' . $technicalName . '.'); + $technicalName = $page['technical_name'] ?? null; + if ((int)$id !== $pageId && is_string($technicalName)) { + $taken[$technicalName] = true; } } - return $technicalName; + return $taken; } /** diff --git a/openapi.json b/openapi.json index 53d1c5186e..91a9cb9a92 100644 --- a/openapi.json +++ b/openapi.json @@ -579,8 +579,7 @@ "nullable": true }, "technical_name": { - "type": "string", - "nullable": true + "type": "string" }, "content": { "type": "array", @@ -14703,7 +14702,7 @@ "type": "string", "nullable": true, "default": null, - "description": "New technical name, null keeps it and an empty string removes it" + "description": "New technical name, unique within the application; null keeps it" } } } @@ -15425,7 +15424,7 @@ "type": "string", "nullable": true, "default": null, - "description": "Name to address the page by, unique within the application" + "description": "Name to address the page by, unique within the application; null makes one from the title" } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 941cdffe3c..f1cc0eb732 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1297,7 +1297,7 @@ export type components = { readonly id: number; readonly page_type: string; readonly title: string | null; - readonly technical_name: string | null; + readonly technical_name: string; readonly content: readonly components["schemas"]["PageContent"][]; }; readonly PageContent: { @@ -8205,7 +8205,7 @@ export interface operations { */ readonly title?: string | null; /** - * @description New technical name, null keeps it and an empty string removes it + * @description New technical name, unique within the application; null keeps it * @default null */ readonly technicalName?: string | null; @@ -8401,7 +8401,7 @@ export interface operations { /** @description Title of the page */ readonly title: string; /** - * @description Name to address the page by, unique within the application + * @description Name to address the page by, unique within the application; null makes one from the title * @default null */ readonly technicalName?: string | null; diff --git a/tests/unit/Controller/ContextPageControllerTest.php b/tests/unit/Controller/ContextPageControllerTest.php index 4b3db9b323..17e15c6bac 100644 --- a/tests/unit/Controller/ContextPageControllerTest.php +++ b/tests/unit/Controller/ContextPageControllerTest.php @@ -40,7 +40,7 @@ protected function setUp(): void { $context->id = 7; $context->setNodes([20 => ['id' => 20, 'node_id' => 3, 'node_type' => 0, 'permissions' => 1]]); $context->setPages([ - 1 => ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'technical_name' => null, 'content' => []], + 1 => ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'technical_name' => 'dashboard', 'content' => []], 2 => ['id' => 2, 'page_type' => Page::TYPE_GRID, 'title' => 'Sales', 'technical_name' => 'sales', 'content' => []], ]); $contextMapper = $this->createMock(ContextMapper::class); @@ -120,6 +120,15 @@ public function testAddingANodeTheManagerCannotManageIsForbidden(): void { $this->assertSame(Http::STATUS_FORBIDDEN, $response->getStatus()); } + public function testAnInvalidOrDuplicateTechnicalNameIsABadRequest(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->create(7, 'Overview', 'Not valid')->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->create(7, 'Overview', 'sales')->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->update(7, 2, null, 'dashboard')->getStatus()); + $this->assertSame(Http::STATUS_BAD_REQUEST, $this->controller->update(7, 2, null, '')->getStatus()); + } + public function testDeletingTheStartPageIsABadRequest(): void { $this->permissionsService->method('canManageContextById')->willReturn(true); diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php index deb97b52f3..0ab98d5aee 100644 --- a/tests/unit/Service/PageServiceTest.php +++ b/tests/unit/Service/PageServiceTest.php @@ -24,6 +24,7 @@ use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\PageService; use OCA\Tables\Service\PermissionsService; +use OCP\DB\Exception as DbException; use OCP\IDBConnection; use OCP\IL10N; use PHPUnit\Framework\MockObject\MockObject; @@ -67,7 +68,7 @@ protected function setUp(): void { 20 => ['id' => 20, 'node_id' => 3, 'node_type' => Application::NODE_TYPE_TABLE, 'permissions' => 1], ]); $this->context->setPages([ - 1 => ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'technical_name' => null, 'content' => [ + 1 => ['id' => 1, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'technical_name' => 'dashboard', 'content' => [ 11 => ['id' => 11, 'order' => 10, 'node_rel_id' => 20, 'type' => 'data', 'configuration' => null, 'grid_x' => null, 'grid_y' => null, 'grid_width' => null, 'grid_height' => null], ]], 2 => ['id' => 2, 'page_type' => Page::TYPE_GRID, 'title' => 'Sales', 'technical_name' => 'sales', 'content' => []], @@ -129,6 +130,9 @@ public static function invalidPageInput(): array { 'technical name with capitals' => ['Ok', 'Overview'], 'technical name starting with a digit' => ['Ok', '1st'], 'technical name taken' => ['Ok', 'sales'], + 'technical name of the start page' => ['Ok', 'dashboard'], + 'empty technical name' => ['Ok', ''], + 'technical name too long' => ['Ok', str_repeat('a', 201)], ]; } @@ -179,19 +183,92 @@ public function testUpdateRenamesAndKeepsItsOwnTechnicalName(): void { $this->assertSame('sales', $stored->getTechnicalName()); } - public function testUpdateClearsTheTechnicalNameWithAnEmptyString(): void { + public static function invalidTechnicalNameChange(): array { + return [ + 'empty' => [''], + 'invalid' => ['Sales report'], + 'taken by another page' => ['dashboard'], + ]; + } + + /** + * @dataProvider invalidTechnicalNameChange + */ + public function testUpdateRejectsAnInvalidTechnicalName(string $technicalName): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = new Page(); + $stored->id = 2; + $stored->setTechnicalName('sales'); + $this->pageMapper->method('findById')->willReturn($stored); + $this->pageMapper->expects($this->never())->method('update'); + + try { + $this->service->update(7, 2, null, $technicalName, self::USER); + $this->fail('The technical name was accepted'); + } catch (BadRequestError) { + } + $this->assertSame('sales', $stored->getTechnicalName()); + } + + public function testUpdateChangesTheTechnicalName(): void { $this->permissionsService->method('canManageContextById')->willReturn(true); $stored = new Page(); $stored->id = 2; $stored->setTitle('Sales'); $stored->setTechnicalName('sales'); $this->pageMapper->method('findById')->willReturn($stored); - $this->pageMapper->method('update')->willReturnArgument(0); + $this->pageMapper->expects($this->once())->method('update')->with($stored)->willReturnArgument(0); - $this->service->update(7, 2, null, '', self::USER); + $this->service->update(7, 2, null, 'revenue', self::USER); $this->assertSame('Sales', $stored->getTitle()); - $this->assertNull($stored->getTechnicalName()); + $this->assertSame('revenue', $stored->getTechnicalName()); + } + + public function testUpdateLosingARaceForATechnicalNameIsABadRequest(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = new Page(); + $stored->id = 2; + $stored->setTechnicalName('sales'); + $this->pageMapper->method('findById')->willReturn($stored); + $this->pageMapper->method('update')->willThrowException(new class('duplicate') extends DbException { + public function getReason(): ?int { + return self::REASON_UNIQUE_CONSTRAINT_VIOLATION; + } + }); + + $this->expectException(BadRequestError::class); + $this->service->update(7, 2, null, 'revenue', self::USER); + } + + public static function generatedTechnicalNames(): array { + return [ + 'from the title' => ['Team overview', 'team_overview'], + 'made unique' => ['Sales', 'sales_2'], + 'start page name taken' => ['Dashboard', 'dashboard_2'], + 'title without letters' => ['2026', 'page_2026'], + ]; + } + + /** + * @dataProvider generatedTechnicalNames + */ + public function testCreateWithoutTechnicalNameMakesOneFromTheTitle(string $title, string $expected): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $inserted = null; + $this->pageMapper->expects($this->once())->method('insert')->willReturnCallback(function (Page $page) use (&$inserted): Page { + $inserted = $page; + $page->id = 3; + $pages = $this->context->getPages(); + $pages[3] = ['id' => 3, 'page_type' => Page::TYPE_GRID, 'title' => $page->getTitle(), 'technical_name' => $page->getTechnicalName(), 'content' => []]; + $this->context->setPages($pages); + return $page; + }); + + $page = $this->service->create(7, $title, null, self::USER); + + $this->assertSame($expected, $inserted->getTechnicalName()); + $this->assertSame($expected, $page['technical_name']); } public function testStartPageCannotBeDeleted(): void {