From 0c61c46a74c061ba21c162680a1b0af273d557e1 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 16 Oct 2026 09:33:40 +0200 Subject: [PATCH] feat(pages): add the page and widget type API GET, POST, PATCH and DELETE /api/2/contexts/{contextId}/pages[/{pageId}] and GET /api/2/widget-types per nextcloud/tables#3100. PATCH changes the title, emoji and description and syncs the widgets of a grid page by id in one transaction. Settings are validated against the schema of their widget type; a data widget's source must be a node of the application, or a table or view the user manages, which is added to the application first. Imported widgets are validated the same way. Assisted-by: ClaudeCode:claude-opus-5-5 --- appinfo/routes.php | 5 + lib/Controller/ContextPageController.php | 192 +++ lib/Db/PageMapper.php | 17 + lib/Db/WidgetSettingMapper.php | 7 +- lib/ResponseDefinitions.php | 41 + lib/Service/ContextService.php | 14 +- lib/Service/GridWidgetTypes.php | 167 +++ lib/Service/PageService.php | 396 +++++ lib/Service/WidgetService.php | 44 +- openapi.json | 1327 +++++++++++++++++ src/types/openapi/openapi.ts | 597 +++++++- .../Controller/ContextPageControllerTest.php | 121 ++ .../Service/ContextPagesRoundTripTest.php | 11 +- tests/unit/Service/ContextServiceTest.php | 15 +- tests/unit/Service/GridWidgetTypesTest.php | 82 + tests/unit/Service/PageServiceTest.php | 279 ++++ tests/unit/Service/WidgetServiceTest.php | 30 + 17 files changed, 3328 insertions(+), 17 deletions(-) create mode 100644 lib/Controller/ContextPageController.php create mode 100644 lib/Service/GridWidgetTypes.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..2239cfd088 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -160,6 +160,11 @@ ['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#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#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..ab6e63853c --- /dev/null +++ b/lib/Controller/ContextPageController.php @@ -0,0 +1,192 @@ +|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] Add a grid page to an application + * + * @param int $contextId ID of the application + * @param string $title Title of the page + * @param string|null $emoji Emoji shown with the title + * @param string|null $description Description of the page + * @return DataResponse|DataResponse + * + * 200: Page created + * 400: Invalid title, emoji or description + * 403: No permissions + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + public function create(int $contextId, string $title, ?string $emoji = null, ?string $description = null): DataResponse { + try { + return new DataResponse($this->pageService->create($contextId, $title, $emoji, $description, $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 a page or its widgets + * + * Fields left out keep their value; an empty emoji or description removes it. When + * `widgets` is given, the widgets of the grid page are made to match it in one + * transaction: listed widgets with an `id` are updated, listed widgets without one + * are added and widgets that are not listed are deleted. + * + * A widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and + * `gridHeight` on a grid of 12 columns and `settings` following the schema of its + * type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or + * view of this application, `{nodeType, nodeId}` of a table or view the user manages, + * which is added to the application, or null. + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @param string|null $title New title + * @param string|null $emoji New emoji, empty to remove it + * @param string|null $description New description, empty to remove it + * @param list>|null $widgets All widgets of the page + * @return DataResponse|DataResponse + * + * 200: Page updated + * 400: Invalid page field or widget + * 403: No permissions on the application or on a table or view to add + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + public function update(int $contextId, int $pageId, ?string $title = null, ?string $emoji = null, ?string $description = null, ?array $widgets = null): DataResponse { + try { + return new DataResponse($this->pageService->update($contextId, $pageId, $title, $emoji, $description, $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 (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] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + 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] Get the widget types a grid page can hold + * + * Every type carries the schema of its settings and whether it shows a table or view. + * + * @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/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/Db/WidgetSettingMapper.php b/lib/Db/WidgetSettingMapper.php index 7b2c834e1a..c58d2848ac 100644 --- a/lib/Db/WidgetSettingMapper.php +++ b/lib/Db/WidgetSettingMapper.php @@ -9,6 +9,7 @@ namespace OCA\Tables\Db; +use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\QBMapper; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -18,7 +19,7 @@ * Reads and writes widget settings as name => value maps. Values are stored JSON * encoded, one row per setting. * - * @psalm-type TablesWidgetSettingValue = string|int|float|bool|null + * @psalm-import-type TablesWidgetValue from ResponseDefinitions * @template-extends QBMapper */ class WidgetSettingMapper extends QBMapper { @@ -30,7 +31,7 @@ public function __construct(IDBConnection $db) { /** * @param int[] $widgetIds - * @return array> settings by widget id + * @return array> settings by widget id * @throws Exception */ public function findSettingsByWidgetIds(array $widgetIds): array { @@ -57,7 +58,7 @@ public function findSettingsByWidgetIds(array $widgetIds): array { /** * Replaces all settings of a widget. * - * @param array $settings + * @param array $settings * @throws Exception * @throws \JsonException */ diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 194aa0a24a..e78044cc2a 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -237,6 +237,47 @@ * ownerType: int, * } * + * @psalm-type TablesWidgetValue = string|int|float|bool|null + * + * @psalm-type TablesWidgetSettingSchema = array{ + * type: 'string'|'text'|'color'|'boolean'|'enum', + * title: string, + * default: TablesWidgetValue, + * options?: list, + * required?: bool, + * } + * + * @psalm-type TablesWidgetType = array{ + * type: string, + * title: string, + * defaultWidth: int, + * defaultHeight: int, + * hasSource: bool, + * settings: array, + * } + * + * @psalm-type TablesWidget = array{ + * id: int, + * type: string, + * title: ?string, + * gridX: int, + * gridY: int, + * gridWidth: int, + * gridHeight: int, + * settings: array, + * source: ?array{nodeRelId: int}, + * } + * + * @psalm-type TablesPage = array{ + * id: int, + * contextId: int, + * pageType: string, + * title: ?string, + * emoji: ?string, + * description: ?string, + * widgets: list, + * } + * * @psalm-type TablesContextNavigation = array{ * id: int, * shareId: int, diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index c05283ce6f..78e806e429 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -63,6 +63,7 @@ public function __construct( private TableMapper $tableMapper, private ViewMapper $viewMapper, private WidgetService $widgetService, + private GridWidgetTypes $gridWidgetTypes, ) { } @@ -683,7 +684,8 @@ public function getPagesWithWidgets(Context $context): array { /** * Inserts an exported widget on a page. Its source is remapped to the imported node - * relation, and dropped when that node was not imported. + * relation, and dropped when that node was not imported. A widget whose type or + * settings are not valid is skipped. * * @param array $nodeRelMap imported node relations by their exported id * @throws Exception @@ -693,9 +695,15 @@ private function importWidget(int $pageId, mixed $widgetArr, array $nodeRelMap): if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { return; } - $oldNodeRelId = $widgetArr['source']['nodeRelId'] ?? null; + try { + $hasSource = $this->gridWidgetTypes->get($widgetArr['type'])['hasSource']; + $settings = $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null); + } catch (BadRequestError $e) { + $this->logger->warning('Skipping an imported widget: ' . $e->getMessage(), ['exception' => $e]); + return; + } + $oldNodeRelId = $hasSource ? ($widgetArr['source']['nodeRelId'] ?? null) : null; $newNodeRel = $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null; - $settings = is_array($widgetArr['settings'] ?? null) ? $widgetArr['settings'] : []; $this->widgetService->insert( $pageId, $widgetArr['type'], diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php new file mode 100644 index 0000000000..802a4e8649 --- /dev/null +++ b/lib/Service/GridWidgetTypes.php @@ -0,0 +1,167 @@ + + */ + public function all(): array { + return [ + self::TYPE_HEADER => [ + 'type' => self::TYPE_HEADER, + 'title' => $this->l10n->t('Header'), + 'defaultWidth' => 12, + 'defaultHeight' => 2, + 'hasSource' => false, + 'settings' => [ + 'heading' => ['type' => 'string', 'title' => $this->l10n->t('Heading'), 'default' => '', 'required' => true], + 'subheading' => ['type' => 'string', 'title' => $this->l10n->t('Subheading'), 'default' => ''], + 'textAlign' => ['type' => 'enum', 'title' => $this->l10n->t('Text alignment'), 'default' => 'left', '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'), 'default' => ''], + 'textColor' => ['type' => 'color', 'title' => $this->l10n->t('Text color'), 'default' => ''], + ], + ], + self::TYPE_TEXT => [ + 'type' => self::TYPE_TEXT, + 'title' => $this->l10n->t('Description'), + 'defaultWidth' => 6, + 'defaultHeight' => 2, + 'hasSource' => false, + 'settings' => [ + 'text' => ['type' => 'text', 'title' => $this->l10n->t('Text'), 'default' => ''], + ], + ], + self::TYPE_DATA => [ + 'type' => self::TYPE_DATA, + 'title' => $this->l10n->t('Table or view'), + 'defaultWidth' => 12, + 'defaultHeight' => 5, + 'hasSource' => true, + 'settings' => [], + ], + ]; + } + + /** + * @return TablesWidgetType + * @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 settings of a widget against the schema of its type and returns them + * with defaults filled in and unknown settings dropped. + * + * @return array + * @throws BadRequestError + */ + public function sanitizeSettings(string $type, mixed $settings): array { + $schema = $this->get($type)['settings']; + $settings ??= []; + if (!is_array($settings)) { + throw new BadRequestError('The settings of a ' . $type . ' widget must be an object.'); + } + $sanitized = []; + foreach ($schema as $name => $property) { + $value = array_key_exists($name, $settings) ? $settings[$name] : $property['default']; + $sanitized[$name] = $this->sanitizeValue($type . '.' . $name, $property, $value); + } + return $sanitized; + } + + /** + * @param TablesWidgetSettingSchema $property + * @return TablesWidgetValue + * @throws BadRequestError + */ + private function sanitizeValue(string $label, array $property, mixed $value): string|bool|int|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 setting ' . $label . ' must be true or false.'), + 'enum' => $this->sanitizeEnum($label, $value, array_column($property['options'] ?? [], 'value')), + }; + } + + /** + * @throws BadRequestError + */ + private function sanitizeText(string $label, mixed $value, bool $isRequired): string { + $value ??= ''; + if (!is_string($value)) { + throw new BadRequestError('Widget setting ' . $label . ' must be a string.'); + } + if ($isRequired && trim($value) === '') { + throw new BadRequestError('Widget setting ' . $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 setting ' . $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 setting ' . $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..661de34643 --- /dev/null +++ b/lib/Service/PageService.php @@ -0,0 +1,396 @@ +, nodeRelId: ?int, nodeKey: ?string} + */ +class PageService { + use TTransactional; + + private const TITLE_MAX_LENGTH = 200; + private const EMOJI_MAX_LENGTH = 32; + private const DESCRIPTION_MAX_LENGTH = 2000; + private const GRID_MAX_HEIGHT = 100; + private const GRID_MAX_Y = 10000; + + public function __construct( + private readonly ContextMapper $contextMapper, + private readonly PageMapper $pageMapper, + private readonly PageContentMapper $pageContentMapper, + private readonly ContextService $contextService, + private readonly WidgetService $widgetService, + private readonly PermissionsService $permissionsService, + private readonly GridWidgetTypes $gridWidgetTypes, + private readonly IDBConnection $dbc, + ) { + } + + /** + * @return TablesPage + * @throws NotFoundError + * @throws Exception + */ + public function find(int $contextId, int $pageId, string $userId): array { + $context = $this->contextMapper->findById($contextId, $userId); + $page = $this->getPageArray($context, $pageId); + $widgets = $this->widgetService->findByPageIds([$pageId]); + + return [ + 'id' => $pageId, + 'contextId' => $contextId, + 'pageType' => (string)$page['page_type'], + 'title' => $page['title'] ?? null, + 'emoji' => $page['emoji'] ?? null, + 'description' => $page['description'] ?? null, + 'widgets' => $widgets[$pageId] ?? [], + ]; + } + + /** + * Adds a grid page to an application. + * + * @return TablesPage + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function create(int $contextId, string $title, ?string $emoji, ?string $description, string $userId): array { + $this->getManageableContext($contextId, $userId); + + $page = new Page(); + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + $page->setTitle($this->validateTitle($title)); + $page->setEmoji($this->validateEmoji($emoji)); + $page->setDescription($this->validateDescription($description)); + $page = $this->pageMapper->insert($page); + + return $this->find($contextId, $page->getId(), $userId); + } + + /** + * Changes the fields of a page and, when widgets are given, makes its widgets match + * them in one transaction. A null argument keeps the current value; an empty emoji + * or description removes it. + * + * A widget has a `type`, an optional `id` of a widget of this page, a `title`, + * `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns and + * `settings` following the schema of its type. A widget of a type with a source has + * a `source`: `{nodeRelId}` of a node of this application, `{nodeType, nodeId}` of a + * table or view that is added to the application, or null. + * + * @param list|null $widgets + * @return TablesPage + * @throws DoesNotExistException + * @throws MultipleObjectsReturnedException + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function update(int $contextId, int $pageId, ?string $title, ?string $emoji, ?string $description, ?array $widgets, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + $pageArray = $this->getPageArray($context, $pageId); + + $page = $this->pageMapper->findById($pageId); + if ($title !== null) { + $page->setTitle($this->validateTitle($title)); + } + if ($emoji !== null) { + $page->setEmoji($this->validateEmoji($emoji)); + } + if ($description !== null) { + $page->setDescription($this->validateDescription($description)); + } + + $rows = null; + $nodesToAdd = []; + if ($widgets !== null) { + if ($pageArray['page_type'] !== Page::TYPE_GRID) { + throw new BadRequestError('Only grid pages hold widgets.'); + } + [$rows, $nodesToAdd] = $this->readWidgets($widgets, $context, $pageId, $userId); + } + + $this->atomic(function () use ($page, $rows, $nodesToAdd, $contextId, $pageId, $userId): void { + if ($page->getUpdatedFields() !== []) { + $this->pageMapper->update($page); + } + if ($rows === null) { + return; + } + $addedRelIds = []; + foreach ($nodesToAdd as $key => $node) { + [$relation] = $this->contextService->addNodeToContextAndStartpage($contextId, $node['nodeId'], $node['nodeType'], Application::PERMISSION_READ, 100, $userId); + $addedRelIds[$key] = $relation->getId(); + } + $this->widgetService->syncPage($pageId, array_map(static function (array $row) use ($addedRelIds): array { + if ($row['nodeKey'] !== null) { + $row['nodeRelId'] = $addedRelIds[$row['nodeKey']]; + } + unset($row['nodeKey']); + return $row; + }, $rows)); + }, $this->dbc); + + return $this->find($contextId, $pageId, $userId); + } + + /** + * Deletes a grid page with 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); + $pageArray = $this->getPageArray($context, $pageId); + if ($pageArray['page_type'] !== Page::TYPE_GRID) { + throw new BadRequestError('The start page of an application cannot be deleted.'); + } + $page = $this->find($contextId, $pageId, $userId); + + $this->atomic(function () use ($pageId): void { + $this->widgetService->deleteByPageIds([$pageId]); + $this->pageContentMapper->deleteByPageId($pageId); + $this->pageMapper->deleteByPageId($pageId); + }, $this->dbc); + + return $page; + } + + /** + * @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 list $widgets + * @return array{0: list, 1: array} the widgets, and the nodes to add by node key + * @throws BadRequestError + * @throws PermissionError + * @throws Exception + */ + private function readWidgets(array $widgets, Context $context, int $pageId, string $userId): array { + $existingIds = array_flip($this->widgetService->findIdsByPageId($pageId)); + $relIdsByNodeKey = []; + foreach ($context->getNodes() ?? [] as $relId => $node) { + $relIdsByNodeKey[$this->nodeKey((int)$node['node_type'], (int)$node['node_id'])] = (int)$relId; + } + + $rows = []; + $nodesToAdd = []; + $seenIds = []; + 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.'); + } + $typeSchema = $this->gridWidgetTypes->get($type); + + $id = $widget['id'] ?? null; + if ($id !== null && (!is_int($id) || !isset($existingIds[$id]) || isset($seenIds[$id]))) { + throw new BadRequestError('Widget ' . $index . ' has the id of no other widget on this page.'); + } + if ($id !== null) { + $seenIds[$id] = true; + } + + $row = [ + 'id' => $id, + 'type' => $type, + 'title' => $this->validateWidgetTitle($index, $widget['title'] ?? null), + 'position' => $this->validatePosition($index, $widget), + 'settings' => $this->gridWidgetTypes->sanitizeSettings($type, $widget['settings'] ?? null), + 'nodeRelId' => null, + 'nodeKey' => null, + ]; + + $source = $widget['source'] ?? null; + if ($source !== null) { + if (!$typeSchema['hasSource']) { + throw new BadRequestError('Widget ' . $index . ' is a ' . $type . ' widget, which shows no table or view.'); + } + [$row['nodeRelId'], $newNode] = $this->resolveSource($index, $source, $context, $relIdsByNodeKey); + if ($newNode !== null) { + $row['nodeKey'] = $this->nodeKey($newNode['nodeType'], $newNode['nodeId']); + $nodesToAdd[$row['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'])); + } + } + + return [$rows, $nodesToAdd]; + } + + /** + * The node relation of a data widget, or the table or view to add to the application. + * + * @param array $relIdsByNodeKey + * @return array{0: ?int, 1: ?array{nodeType: int, nodeId: int}} + * @throws BadRequestError + */ + private function resolveSource(int $index, mixed $source, Context $context, array $relIdsByNodeKey): array { + if (!is_array($source)) { + throw new BadRequestError('The source of widget ' . $index . ' must be an object.'); + } + if (array_key_exists('nodeRelId', $source)) { + $nodeRelId = $source['nodeRelId']; + if (!is_int($nodeRelId) || !isset(($context->getNodes() ?? [])[$nodeRelId])) { + throw new BadRequestError('Widget ' . $index . ' shows a table or view that is not part of this application.'); + } + return [$nodeRelId, null]; + } + + $nodeType = $source['nodeType'] ?? null; + $nodeId = $source['nodeId'] ?? null; + if (!in_array($nodeType, [Application::NODE_TYPE_TABLE, Application::NODE_TYPE_VIEW], true) || !is_int($nodeId) || $nodeId <= 0) { + throw new BadRequestError('The source of widget ' . $index . ' needs a nodeRelId of this application, or a nodeType and nodeId of a table or view.'); + } + $key = $this->nodeKey($nodeType, $nodeId); + if (isset($relIdsByNodeKey[$key])) { + return [$relIdsByNodeKey[$key], null]; + } + return [null, ['nodeType' => $nodeType, 'nodeId' => $nodeId]]; + } + + /** + * @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; + } + + /** + * @throws BadRequestError + */ + private function validateEmoji(?string $emoji): ?string { + $emoji = trim((string)$emoji); + if (mb_strlen($emoji) > self::EMOJI_MAX_LENGTH) { + throw new BadRequestError('An emoji has at most ' . self::EMOJI_MAX_LENGTH . ' characters.'); + } + return $emoji === '' ? null : $emoji; + } + + /** + * @throws BadRequestError + */ + private function validateDescription(?string $description): ?string { + $description = trim((string)$description); + if (mb_strlen($description) > self::DESCRIPTION_MAX_LENGTH) { + throw new BadRequestError('A page description has at most ' . self::DESCRIPTION_MAX_LENGTH . ' characters.'); + } + return $description === '' ? null : $description; + } + + /** + * @throws BadRequestError + */ + private function validateWidgetTitle(int $index, mixed $title): ?string { + if ($title === null) { + return null; + } + if (!is_string($title) || mb_strlen(trim($title)) > self::TITLE_MAX_LENGTH) { + throw new BadRequestError('The title of widget ' . $index . ' must be a text of at most ' . self::TITLE_MAX_LENGTH . ' characters.'); + } + $title = trim($title); + return $title === '' ? null : $title; + } + + /** + * @param array $widget + * @return array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} + * @throws BadRequestError + */ + private function validatePosition(int $index, array $widget): array { + $limits = [ + 'gridX' => [0, GridWidgetTypes::GRID_COLUMNS - 1], + 'gridY' => [0, self::GRID_MAX_Y], + 'gridWidth' => [1, GridWidgetTypes::GRID_COLUMNS], + 'gridHeight' => [1, self::GRID_MAX_HEIGHT], + ]; + $position = []; + foreach ($limits as $name => [$minimum, $maximum]) { + $value = $widget[$name] ?? null; + if (!is_int($value) || $value < $minimum || $value > $maximum) { + throw new BadRequestError('Widget ' . $index . ' needs ' . $name . ' as a whole number from ' . $minimum . ' to ' . $maximum . '.'); + } + $position[$name] = $value; + } + if ($position['gridX'] + $position['gridWidth'] > GridWidgetTypes::GRID_COLUMNS) { + throw new BadRequestError('Widget ' . $index . ' does not fit in ' . GridWidgetTypes::GRID_COLUMNS . ' columns.'); + } + return $position; + } + + private function nodeKey(int $nodeType, int $nodeId): string { + return sprintf('t%di%d', $nodeType, $nodeId); + } +} diff --git a/lib/Service/WidgetService.php b/lib/Service/WidgetService.php index 8279d6e26b..74c8583e10 100644 --- a/lib/Service/WidgetService.php +++ b/lib/Service/WidgetService.php @@ -13,14 +13,15 @@ use OCA\Tables\Db\WidgetMapper; use OCA\Tables\Db\WidgetSettingMapper; use OCA\Tables\Db\WidgetSourceMapper; +use OCA\Tables\ResponseDefinitions; use OCP\DB\Exception; /** * Stores the widgets of grid pages together with their settings and the node a * data widget shows. Callers check permissions and run writes in a transaction. * - * @psalm-import-type TablesWidgetSettingValue from WidgetSettingMapper - * @psalm-type TablesWidgetRecord = array{id: int, type: string, title: ?string, gridX: int, gridY: int, gridWidth: int, gridHeight: int, settings: array, source: ?array{nodeRelId: int}} + * @psalm-import-type TablesWidget from ResponseDefinitions + * @psalm-import-type TablesWidgetValue from ResponseDefinitions */ class WidgetService { public function __construct( @@ -32,7 +33,7 @@ public function __construct( /** * @param int[] $pageIds - * @return array> widgets by page id, in grid order + * @return array> widgets by page id, in grid order * @throws Exception */ public function findByPageIds(array $pageIds): array { @@ -61,7 +62,7 @@ public function findByPageIds(array $pageIds): array { /** * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position - * @param array $settings + * @param array $settings * @throws Exception * @throws \JsonException */ @@ -76,6 +77,41 @@ public function insert(int $pageId, string $type, ?string $title, array $positio return $widget; } + /** + * @return int[] + * @throws Exception + */ + public function findIdsByPageId(int $pageId): array { + return $this->widgetMapper->findIdsByPageId($pageId); + } + + /** + * Makes the widgets of a page match the given list: widgets with an id are updated, + * widgets without one are inserted and widgets of the page that are not listed are + * deleted. The ids must belong to the page. + * + * @param list, nodeRelId: ?int}> $widgets + * @throws Exception + * @throws \JsonException + */ + public function syncPage(int $pageId, array $widgets): void { + $listedIds = array_filter(array_column($widgets, 'id'), static fn (?int $id): bool => $id !== null); + $this->deleteByIds(array_values(array_diff($this->widgetMapper->findIdsByPageId($pageId), $listedIds))); + + foreach ($widgets as $data) { + if ($data['id'] === null) { + $this->insert($pageId, $data['type'], $data['title'], $data['position'], $data['settings'], $data['nodeRelId']); + continue; + } + $widget = new Widget(); + $widget->setId($data['id']); + $this->applyFields($widget, $data['type'], $data['title'], $data['position']); + $this->widgetMapper->update($widget); + $this->widgetSettingMapper->replaceSettings($data['id'], $data['settings']); + $this->widgetSourceMapper->setSource($data['id'], $data['nodeRelId']); + } + } + /** * Deletes the widgets of the given pages with their settings and sources. * diff --git a/openapi.json b/openapi.json index c140ed6423..4e26f99769 100644 --- a/openapi.json +++ b/openapi.json @@ -451,6 +451,49 @@ } } }, + "Page": { + "type": "object", + "required": [ + "id", + "contextId", + "pageType", + "title", + "emoji", + "description", + "widgets" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "contextId": { + "type": "integer", + "format": "int64" + }, + "pageType": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "emoji": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Widget" + } + } + } + }, "PublicColumn": { "type": "object", "required": [ @@ -1288,6 +1331,170 @@ "nullable": true } } + }, + "Widget": { + "type": "object", + "required": [ + "id", + "type", + "title", + "gridX", + "gridY", + "gridWidth", + "gridHeight", + "settings", + "source" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "gridX": { + "type": "integer", + "format": "int64" + }, + "gridY": { + "type": "integer", + "format": "int64" + }, + "gridWidth": { + "type": "integer", + "format": "int64" + }, + "gridHeight": { + "type": "integer", + "format": "int64" + }, + "settings": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/WidgetValue" + } + }, + "source": { + "type": "object", + "nullable": true, + "required": [ + "nodeRelId" + ], + "properties": { + "nodeRelId": { + "type": "integer", + "format": "int64" + } + } + } + } + }, + "WidgetSettingSchema": { + "type": "object", + "required": [ + "type", + "title", + "default" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "text", + "color", + "boolean", + "enum" + ] + }, + "title": { + "type": "string" + }, + "default": { + "$ref": "#/components/schemas/WidgetValue" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "required": { + "type": "boolean" + } + } + }, + "WidgetType": { + "type": "object", + "required": [ + "type", + "title", + "defaultWidth", + "defaultHeight", + "hasSource", + "settings" + ], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "defaultWidth": { + "type": "integer", + "format": "int64" + }, + "defaultHeight": { + "type": "integer", + "format": "int64" + }, + "hasSource": { + "type": "boolean" + }, + "settings": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/WidgetSettingSchema" + } + } + } + }, + "WidgetValue": { + "nullable": true, + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "boolean" + } + ] } } }, @@ -14287,6 +14494,1126 @@ } } } + }, + "get": { + "operationId": "context_page-show", + "summary": "[api v2] Get a page of an application with its 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 a page or its widgets", + "description": "Fields left out keep their value; an empty emoji or description removes it. When `widgets` is given, the widgets of the grid page are made to match it in one transaction: listed widgets with an `id` are updated, listed widgets without one are added and widgets that are not listed are deleted.\nA widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns and `settings` following the schema of its type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or view of this application, `{nodeType, nodeId}` of a table or view the user manages, which is added to the application, or null.", + "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" + }, + "emoji": { + "type": "string", + "nullable": true, + "default": null, + "description": "New emoji, empty to remove it" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "New description, empty to remove it" + }, + "widgets": { + "type": "array", + "nullable": true, + "default": null, + "description": "All 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": "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 page field or 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": {} + } + } + } + } + } + } + } + } + }, + "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": { + "post": { + "operationId": "context_page-create", + "summary": "[api v2] Add a grid page to 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" + }, + "emoji": { + "type": "string", + "nullable": true, + "default": null, + "description": "Emoji shown with the title" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Description of the page" + } + } + } + } + } + }, + "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, emoji or 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" + } + } + } + } + } + } + } + } + } + }, + "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/widget-types": { + "get": { + "operationId": "context_page-widget-types", + "summary": "[api v2] Get the widget types a grid page can hold", + "description": "Every type carries the schema of its settings and whether it shows a table or view.", + "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/WidgetType" + } + } + } + } + } + } + } + } + }, + "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..57781b5a03 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -900,10 +900,57 @@ export type paths = { readonly path?: never; readonly cookie?: never; }; - readonly get?: never; + /** [api v2] Get a page of an application with its 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 a page or its widgets + * @description Fields left out keep their value; an empty emoji or description removes it. When `widgets` is given, the widgets of the grid page are made to match it in one transaction: listed widgets with an `id` are updated, listed widgets without one are added and widgets that are not listed are deleted. + * A widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns and `settings` following the schema of its type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or view of this application, `{nodeType, nodeId}` of a table or view the user manages, which is added to the application, or null. + */ + 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; + }; + readonly get?: never; + readonly put?: never; + /** [api v2] Add a grid page to 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/widget-types": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** + * [api v2] Get the widget types a grid page can hold + * @description Every type carries the schema of its settings and whether it shows a table or view. + */ + readonly get: operations["context_page-widget-types"]; + readonly put?: never; + readonly post?: never; readonly delete?: never; readonly options?: never; readonly head?: never; @@ -1198,6 +1245,17 @@ export type components = { readonly totalitems?: string; readonly itemsperpage?: string; }; + readonly Page: { + /** Format: int64 */ + readonly id: number; + /** Format: int64 */ + readonly contextId: number; + readonly pageType: string; + readonly title: string | null; + readonly emoji: string | null; + readonly description: string | null; + readonly widgets: readonly components["schemas"]["Widget"][]; + }; readonly PublicColumn: { /** Format: int64 */ readonly id: number; @@ -1425,6 +1483,51 @@ export type components = { /** Format: int64 */ readonly sidebarOrder: number | null; }; + readonly Widget: { + /** Format: int64 */ + readonly id: number; + readonly type: string; + readonly title: string | null; + /** Format: int64 */ + readonly gridX: number; + /** Format: int64 */ + readonly gridY: number; + /** Format: int64 */ + readonly gridWidth: number; + /** Format: int64 */ + readonly gridHeight: number; + readonly settings: { + readonly [key: string]: components["schemas"]["WidgetValue"]; + }; + readonly source: { + /** Format: int64 */ + readonly nodeRelId: number; + } | null; + }; + readonly WidgetSettingSchema: { + /** @enum {string} */ + readonly type: "string" | "text" | "color" | "boolean" | "enum"; + readonly title: string; + readonly default: components["schemas"]["WidgetValue"]; + readonly options?: readonly { + readonly value: string; + readonly label: string; + }[]; + readonly required?: boolean; + }; + readonly WidgetType: { + readonly type: string; + readonly title: string; + /** Format: int64 */ + readonly defaultWidth: number; + /** Format: int64 */ + readonly defaultHeight: number; + readonly hasSource: boolean; + readonly settings: { + readonly [key: string]: components["schemas"]["WidgetSettingSchema"]; + }; + }; + readonly WidgetValue: (string | number | boolean) | null; }; responses: never; parameters: never; @@ -7746,6 +7849,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 +8049,420 @@ 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 + * @default null + */ + readonly title?: string | null; + /** + * @description New emoji, empty to remove it + * @default null + */ + readonly emoji?: string | null; + /** + * @description New description, empty to remove it + * @default null + */ + readonly description?: string | null; + /** + * @description All widgets of the page + * @default null + */ + readonly widgets?: readonly { + readonly [key: string]: Record; + }[] | 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 page field or 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-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 Emoji shown with the title + * @default null + */ + readonly emoji?: string | null; + /** + * @description Description of the page + * @default null + */ + readonly description?: 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, emoji or description */ + 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-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"]["WidgetType"][]; + }; + }; + }; + }; + /** @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..0bf720c4df --- /dev/null +++ b/tests/unit/Controller/ContextPageControllerTest.php @@ -0,0 +1,121 @@ +pageService = $this->createMock(PageService::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + $this->controller = new ContextPageController( + $this->createMock(IRequest::class), + $this->createMock(LoggerInterface::class), + $l10n, + 'alice', + $this->pageService, + new GridWidgetTypes($l10n), + ); + } + + public function testShowReturnsThePage(): void { + $page = ['id' => 2, 'contextId' => 7, 'pageType' => 'grid', 'title' => 'Overview', 'emoji' => null, 'description' => null, 'widgets' => []]; + $this->pageService->method('find')->with(7, 2, 'alice')->willReturn($page); + + $response = $this->controller->show(7, 2); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame($page, $response->getData()); + } + + public function testShowAnUnknownPageIsNotFound(): void { + $this->pageService->method('find')->willThrowException(new NotFoundError('Page 9 does not belong to application 7')); + + $this->assertSame(Http::STATUS_NOT_FOUND, $this->controller->show(7, 9)->getStatus()); + } + + public function testCreatePassesThePageFields(): void { + $this->pageService->expects($this->once())->method('create')->with(7, 'Overview', '📊', 'At a glance', 'alice')->willReturn([]); + + $this->assertSame(Http::STATUS_OK, $this->controller->create(7, 'Overview', '📊', 'At a glance')->getStatus()); + } + + public function testUpdatePassesFieldsAndWidgets(): void { + $widgets = [['type' => 'text', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]]; + $this->pageService->expects($this->once())->method('update')->with(7, 2, null, '', null, $widgets, 'alice')->willReturn([]); + + $this->assertSame(Http::STATUS_OK, $this->controller->update(7, 2, null, '', null, $widgets)->getStatus()); + } + + public static function errors(): array { + return [ + 'bad request' => [new BadRequestError('Widget 0 needs a type.'), Http::STATUS_BAD_REQUEST], + 'no permission' => [new PermissionError('User bob cannot manage application 7'), Http::STATUS_FORBIDDEN], + 'not found' => [new NotFoundError('Context does not exist'), Http::STATUS_NOT_FOUND], + 'page gone' => [new DoesNotExistException('Page does not exist'), Http::STATUS_NOT_FOUND], + ]; + } + + /** + * @dataProvider errors + */ + public function testUpdateMapsErrorsToStatusCodes(\Exception $error, int $status): void { + $this->pageService->method('update')->willThrowException($error); + + $response = $this->controller->update(7, 2, 'New'); + + $this->assertSame($status, $response->getStatus()); + $this->assertArrayHasKey('message', $response->getData()); + } + + public function testABadRequestExplainsWhy(): void { + $this->pageService->method('update')->willThrowException(new BadRequestError('Widget 0 needs a type.')); + + $this->assertSame('Widget 0 needs a type.', $this->controller->update(7, 2, null, null, null, [[]])->getData()['message']); + } + + public static function serviceErrors(): array { + return array_slice(self::errors(), 0, 3); + } + + /** + * @dataProvider serviceErrors + */ + public function testCreateAndDestroyMapErrorsToStatusCodes(\Exception $error, int $status): void { + $this->pageService->method('create')->willThrowException($error); + $this->pageService->method('delete')->willThrowException($error); + + $this->assertSame($status, $this->controller->create(7, 'Overview')->getStatus()); + $this->assertSame($status, $this->controller->destroy(7, 2)->getStatus()); + } + + public function testWidgetTypesListsEveryType(): void { + $response = $this->controller->widgetTypes(); + + $this->assertSame(['header', 'text', 'data'], array_column($response->getData(), 'type')); + } +} diff --git a/tests/unit/Service/ContextPagesRoundTripTest.php b/tests/unit/Service/ContextPagesRoundTripTest.php index a7a197efbc..a763019790 100644 --- a/tests/unit/Service/ContextPagesRoundTripTest.php +++ b/tests/unit/Service/ContextPagesRoundTripTest.php @@ -26,11 +26,13 @@ use OCA\Tables\Helper\GroupHelper; use OCA\Tables\Helper\UserHelper; use OCA\Tables\Service\ContextService; +use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\PermissionsService; use OCA\Tables\Service\ShareService; use OCA\Tables\Service\WidgetService; use OCA\Tables\Tests\Unit\Database\DatabaseTestCase; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IL10N; use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUserManager; @@ -53,6 +55,8 @@ class ContextPagesRoundTripTest extends DatabaseTestCase { protected function setUp(): void { parent::setUp(); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); $userHelper = $this->createMock(UserHelper::class); $userHelper->method('getGroupIdsForUser')->willReturn([]); $this->contextMapper = new ContextMapper($this->connectionAdapter, $userHelper, $this->createMock(GroupHelper::class)); @@ -80,6 +84,7 @@ protected function setUp(): void { $this->createMock(TableMapper::class), $this->createMock(ViewMapper::class), $this->widgetService, + new GridWidgetTypes($l10n), ); } @@ -135,7 +140,7 @@ public function testRemovingTheNodeKeepsTheWidgetAndDeletingTheContextRemovesIt( $widgets = $this->widgetService->findByPageIds([$gridPageId])[$gridPageId]; $this->assertCount(2, $widgets); $this->assertNull($widgets[1]['source']); - $this->assertSame(['pageSize' => 25], $widgets[1]['settings']); + $this->assertSame('data', $widgets[1]['type']); $this->service->delete($context->getId(), self::OWNER); @@ -160,8 +165,8 @@ private function createContextWithGridPage(): array { $page->setDescription('At a glance'); $page = $this->pageMapper->insert($page); - $this->widgetService->insert($page->getId(), 'header', 'Welcome', ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], ['text' => 'Projects', 'showTitle' => false], null); - $this->widgetService->insert($page->getId(), 'data', null, ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], ['pageSize' => 25], $relation->getId()); + $this->widgetService->insert($page->getId(), 'header', 'Welcome', ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], ['heading' => 'Projects', 'subheading' => '', 'textAlign' => 'center', 'backgroundColor' => '#123456', 'textColor' => ''], null); + $this->widgetService->insert($page->getId(), 'data', null, ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], [], $relation->getId()); return [$context, $relation, $page->getId()]; } diff --git a/tests/unit/Service/ContextServiceTest.php b/tests/unit/Service/ContextServiceTest.php index 6443fe3e05..76077b4134 100644 --- a/tests/unit/Service/ContextServiceTest.php +++ b/tests/unit/Service/ContextServiceTest.php @@ -22,6 +22,7 @@ use OCA\Tables\Db\ViewMapper; use OCP\EventDispatcher\IEventDispatcher; use OCP\IDBConnection; +use OCP\IL10N; use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\IUserManager; @@ -51,6 +52,8 @@ protected function setUp(): void { $eventDispatcher = $this->createMock(IEventDispatcher::class); $this->dbConnection = $this->createMock(IDBConnection::class); $this->widgetService = $this->createMock(WidgetService::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); $shareService = $this->createMock(ShareService::class); $navigationManager = $this->createMock(INavigationManager::class); $urlGenerator = $this->createMock(IURLGenerator::class); @@ -72,6 +75,7 @@ protected function setUp(): void { $this->createMock(TableMapper::class), $this->createMock(ViewMapper::class), $this->widgetService, + new GridWidgetTypes($l10n), ); } @@ -213,7 +217,7 @@ public function testGetPagesWithWidgetsAddsTheWidgetsOfEachPage(): void { self::assertSame([$widget], $pages[302]['widgets']); } - public function testImportContextRemapsTheSourceOfAWidget(): void { + public function testImportContextRemapsSourcesAndSkipsInvalidWidgets(): void { $table = new Table(); $table->id = 77; $table->setOwnership('user-1'); @@ -249,15 +253,20 @@ public function testImportContextRemapsTheSourceOfAWidget(): void { 'description' => 'At a glance', 'content' => [], 'widgets' => [ - ['type' => 'data', 'title' => 'Tasks', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => ['pageSize' => 10], 'source' => ['nodeRelId' => 101]], + ['type' => 'data', 'title' => 'Tasks', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5, 'settings' => ['unknown' => 10], 'source' => ['nodeRelId' => 101]], ['type' => 'data', 'title' => null, 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 5, 'settings' => [], 'source' => ['nodeRelId' => 999]], + ['type' => 'text', 'title' => null, 'gridX' => 6, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hi'], 'source' => ['nodeRelId' => 101]], + ['type' => 'header', 'title' => null, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2, 'settings' => ['heading' => ''], 'source' => null], + ['type' => 'chart', 'title' => null, 'gridX' => 0, 'gridY' => 9, 'gridWidth' => 12, 'gridHeight' => 2, 'settings' => [], 'source' => null], ], ]], ], 5); self::assertSame(['Overview', '📊', 'At a glance'], [$insertedPages[0]->getTitle(), $insertedPages[0]->getEmoji(), $insertedPages[0]->getDescription()]); - self::assertSame([600, 'data', 'Tasks', ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], ['pageSize' => 10], 501], $inserted[0]); + self::assertCount(3, $inserted); + self::assertSame([600, 'data', 'Tasks', ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 12, 'gridHeight' => 5], [], 501], $inserted[0]); self::assertNull($inserted[1][5]); + self::assertSame([600, 'text', null, ['gridX' => 6, 'gridY' => 7, 'gridWidth' => 6, 'gridHeight' => 2], ['text' => 'Hi'], null], $inserted[2]); } private function buildPageContent(int $id, int $pageId, int $nodeRelId, int $order): PageContent { diff --git a/tests/unit/Service/GridWidgetTypesTest.php b/tests/unit/Service/GridWidgetTypesTest.php new file mode 100644 index 0000000000..b7bc1103ad --- /dev/null +++ b/tests/unit/Service/GridWidgetTypesTest.php @@ -0,0 +1,82 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => '[' . $text . ']'); + $this->types = new GridWidgetTypes($l10n); + } + + public function testEveryTypeDescribesItsSizeSourceAndSettings(): 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($name === 'data', $type['hasSource']); + } + $this->assertSame(['heading', 'subheading', 'textAlign', 'backgroundColor', 'textColor'], array_keys($all['header']['settings'])); + $this->assertSame([12, 5], [$all['data']['defaultWidth'], $all['data']['defaultHeight']]); + $this->assertSame([], $all['data']['settings']); + } + + public function testTitlesAreTranslated(): void { + $header = $this->types->get('header'); + + $this->assertSame('[Header]', $header['title']); + $this->assertSame('[Heading]', $header['settings']['heading']['title']); + $this->assertSame('[Centered]', $header['settings']['textAlign']['options'][1]['label']); + $this->assertSame('[Description]', $this->types->get('text')['title']); + } + + public function testUnknownTypeIsRejected(): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Unknown widget type "chart"'); + $this->types->get('chart'); + } + + public function testSanitizeFillsDefaultsAndDropsUnknownSettings(): void { + $this->assertSame( + ['heading' => 'Welcome', 'subheading' => '', 'textAlign' => 'center', 'backgroundColor' => '#fff', 'textColor' => ''], + $this->types->sanitizeSettings('header', ['heading' => 'Welcome', 'textAlign' => 'center', 'backgroundColor' => '#fff', 'script' => '']), + ); + $this->assertSame(['text' => ''], $this->types->sanitizeSettings('text', null)); + $this->assertSame([], $this->types->sanitizeSettings('data', ['pageSize' => 10])); + } + + public static function invalidSettings(): array { + return [ + 'settings not an object' => ['text', 'x', 'settings of a text widget must be an object'], + 'required heading missing' => ['header', ['heading' => ' '], 'header.heading is required'], + 'text not a string' => ['text', ['text' => 3], 'text.text must be a string'], + 'color not hex' => ['header', ['heading' => 'A', 'textColor' => 'red'], 'header.textColor must be a hex color'], + 'alignment unknown' => ['header', ['heading' => 'A', 'textAlign' => 'justify'], 'header.textAlign must be one of'], + ]; + } + + /** + * @dataProvider invalidSettings + */ + public function testSanitizeRejectsInvalidSettings(string $type, mixed $settings, string $message): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->types->sanitizeSettings($type, $settings); + } +} diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php new file mode 100644 index 0000000000..daf1894fcb --- /dev/null +++ b/tests/unit/Service/PageServiceTest.php @@ -0,0 +1,279 @@ +context = new Context(); + $this->context->id = 7; + $this->context->setNodes([self::NODE_REL => ['id' => self::NODE_REL, 'node_id' => 3, 'node_type' => Application::NODE_TYPE_TABLE, 'permissions' => 1]]); + $this->context->setPages([ + self::START_PAGE => ['id' => self::START_PAGE, 'page_type' => Page::TYPE_STARTPAGE, 'title' => null, 'emoji' => null, 'description' => null, 'content' => []], + self::GRID_PAGE => ['id' => self::GRID_PAGE, 'page_type' => Page::TYPE_GRID, 'title' => 'Overview', 'emoji' => '📊', 'description' => null, 'content' => []], + ]); + $contextMapper = $this->createMock(ContextMapper::class); + $contextMapper->method('findById')->willReturnCallback(function (int $contextId): Context { + if ($contextId !== 7) { + throw new NotFoundError('Context does not exist'); + } + return $this->context; + }); + $this->pageMapper = $this->createMock(PageMapper::class); + $this->pageContentMapper = $this->createMock(PageContentMapper::class); + $this->contextService = $this->createMock(ContextService::class); + $this->widgetService = $this->createMock(WidgetService::class); + $this->widgetService->method('findIdsByPageId')->willReturnMap([[self::GRID_PAGE, [31, 32]], [self::START_PAGE, []]]); + $this->permissionsService = $this->createMock(PermissionsService::class); + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnArgument(0); + + $this->service = new PageService( + $contextMapper, + $this->pageMapper, + $this->pageContentMapper, + $this->contextService, + $this->widgetService, + $this->permissionsService, + new GridWidgetTypes($l10n), + $this->createMock(IDBConnection::class), + ); + } + + public function testFindReturnsThePageWithItsWidgets(): void { + $widget = ['id' => 31, 'type' => 'text', 'title' => null, 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2, 'settings' => ['text' => 'Hi'], 'source' => null]; + $this->widgetService->method('findByPageIds')->with([self::GRID_PAGE])->willReturn([self::GRID_PAGE => [$widget]]); + + $this->assertSame([ + 'id' => self::GRID_PAGE, + 'contextId' => 7, + 'pageType' => Page::TYPE_GRID, + 'title' => 'Overview', + 'emoji' => '📊', + 'description' => null, + 'widgets' => [$widget], + ], $this->service->find(7, self::GRID_PAGE, 'alice')); + } + + public function testFindRejectsAPageOfAnotherApplication(): void { + $this->expectException(NotFoundError::class); + $this->service->find(7, 99, 'alice'); + } + + public function testCreateInsertsAGridPage(): void { + $this->permissionsService->method('canManageContextById')->with(7, 'alice')->willReturn(true); + $inserted = null; + $this->pageMapper->expects($this->once())->method('insert')->willReturnCallback(function (Page $page) use (&$inserted): Page { + $page->id = self::GRID_PAGE; + $inserted = $page; + return $page; + }); + + $this->service->create(7, ' Overview ', ' 📊 ', '', 'alice'); + + $this->assertSame([7, Page::TYPE_GRID, 'Overview', '📊', null], [$inserted->getContextId(), $inserted->getPageType(), $inserted->getTitle(), $inserted->getEmoji(), $inserted->getDescription()]); + } + + public function testCreateNeedsManagePermission(): void { + $this->permissionsService->method('canManageContextById')->willReturn(false); + $this->pageMapper->expects($this->never())->method('insert'); + + $this->expectException(PermissionError::class); + $this->service->create(7, 'Overview', null, null, 'bob'); + } + + public function testCreateNeedsATitle(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('insert'); + + $this->expectException(BadRequestError::class); + $this->service->create(7, ' ', null, null, 'alice'); + } + + public function testUpdateSyncsWidgetsById(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $synced = null; + $this->widgetService->expects($this->once())->method('syncPage')->willReturnCallback(function (int $pageId, array $rows) use (&$synced): void { + $this->assertSame(self::GRID_PAGE, $pageId); + $synced = $rows; + }); + $this->pageMapper->expects($this->never())->method('update'); + + $this->service->update(7, self::GRID_PAGE, null, null, null, [ + ['id' => 31, 'type' => 'header', 'title' => ' Intro ', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2, 'settings' => ['heading' => 'Welcome']], + ['type' => 'data', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5, 'source' => ['nodeRelId' => self::NODE_REL]], + ['type' => 'data', 'gridX' => 6, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5, 'source' => ['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 3]], + ['type' => 'data', 'gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5, 'source' => null], + ], 'alice'); + + $this->assertSame([ + ['id' => 31, 'type' => 'header', 'title' => 'Intro', 'position' => ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2], 'settings' => ['heading' => 'Welcome', 'subheading' => '', 'textAlign' => 'left', 'backgroundColor' => '', 'textColor' => ''], 'nodeRelId' => null], + ['id' => null, 'type' => 'data', 'title' => null, 'position' => ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], 'settings' => [], 'nodeRelId' => self::NODE_REL], + ['id' => null, 'type' => 'data', 'title' => null, 'position' => ['gridX' => 6, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], 'settings' => [], 'nodeRelId' => self::NODE_REL], + ['id' => null, 'type' => 'data', 'title' => null, 'position' => ['gridX' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5], 'settings' => [], 'nodeRelId' => null], + ], $synced); + } + + public function testUpdateAddsATableTheUserManagesToTheApplication(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->permissionsService->expects($this->once())->method('canManageNodeById')->with(Application::NODE_TYPE_VIEW, 8, 'alice')->willReturn(true); + $relation = new ContextNodeRelation(); + $relation->id = 55; + $this->contextService->expects($this->once())->method('addNodeToContextAndStartpage') + ->with(7, 8, Application::NODE_TYPE_VIEW, Application::PERMISSION_READ, 100, 'alice') + ->willReturn([$relation, new PageContent()]); + $this->widgetService->expects($this->once())->method('syncPage')->willReturnCallback(function (int $pageId, array $rows): void { + $this->assertSame([55, 55], array_column($rows, 'nodeRelId')); + $this->assertArrayNotHasKey('nodeKey', $rows[0]); + }); + + $this->service->update(7, self::GRID_PAGE, null, null, null, [ + ['type' => 'data', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 5, 'source' => ['nodeType' => Application::NODE_TYPE_VIEW, 'nodeId' => 8]], + ['type' => 'data', 'gridX' => 6, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 5, 'source' => ['nodeType' => Application::NODE_TYPE_VIEW, 'nodeId' => 8]], + ], 'alice'); + } + + public function testUpdateRefusesATableTheUserDoesNotManage(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->permissionsService->method('canManageNodeById')->willReturn(false); + $this->contextService->expects($this->never())->method('addNodeToContextAndStartpage'); + $this->widgetService->expects($this->never())->method('syncPage'); + + $this->expectException(PermissionError::class); + $this->service->update(7, self::GRID_PAGE, null, null, null, [ + ['type' => 'data', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 5, 'source' => ['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 9]], + ], 'alice'); + } + + public function testUpdateNeedsManagePermission(): void { + $this->permissionsService->method('canManageContextById')->willReturn(false); + $this->widgetService->expects($this->never())->method('syncPage'); + $this->pageMapper->expects($this->never())->method('update'); + + $this->expectException(PermissionError::class); + $this->service->update(7, self::GRID_PAGE, 'New', null, null, [], 'bob'); + } + + public function testUpdateChangesPageFields(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = new Page(); + $stored->id = self::GRID_PAGE; + $stored->setTitle('Overview'); + $stored->setEmoji('📊'); + $stored->resetUpdatedFields(); + $this->pageMapper->method('findById')->with(self::GRID_PAGE)->willReturn($stored); + $updated = null; + $this->pageMapper->expects($this->once())->method('update')->willReturnCallback(function (Page $page) use (&$updated): Page { + $updated = $page; + return $page; + }); + $this->widgetService->expects($this->never())->method('syncPage'); + + $this->service->update(7, self::GRID_PAGE, 'Planning', '', 'What is next', null, 'alice'); + + $this->assertSame([self::GRID_PAGE, 'Planning', null, 'What is next'], [$updated->getId(), $updated->getTitle(), $updated->getEmoji(), $updated->getDescription()]); + $this->assertSame(['title', 'emoji', 'description'], array_keys($updated->getUpdatedFields())); + } + + public static function invalidWidgets(): array { + $at = ['gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]; + return [ + 'not an object' => [['x'], 'Widget 0 must be an object'], + 'no type' => [[$at], 'Widget 0 needs a type'], + 'unknown type' => [[['type' => 'chart'] + $at], 'Unknown widget type'], + 'id of another page' => [[['id' => 99, 'type' => 'text'] + $at], 'Widget 0 has the id of no other widget on this page'], + 'id twice' => [[['id' => 31, 'type' => 'text'] + $at, ['id' => 31, 'type' => 'text'] + $at], 'Widget 1 has the id'], + 'invalid setting' => [[['type' => 'header', 'settings' => ['heading' => '']] + $at], 'header.heading is required'], + 'outside the grid' => [[['type' => 'text', 'gridX' => 8, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]], 'does not fit in 12 columns'], + 'no position' => [[['type' => 'text']], 'Widget 0 needs gridX'], + 'source on a header' => [[['type' => 'header', 'settings' => ['heading' => 'A'], 'source' => ['nodeRelId' => self::NODE_REL]] + $at], 'shows no table or view'], + 'node of another application' => [[['type' => 'data', 'source' => ['nodeRelId' => 21]] + $at], 'not part of this application'], + 'invalid node' => [[['type' => 'data', 'source' => ['nodeType' => 5, 'nodeId' => 1]] + $at], 'needs a nodeRelId of this application'], + 'title not text' => [[['type' => 'text', 'title' => 5] + $at], 'The title of widget 0'], + ]; + } + + /** + * @dataProvider invalidWidgets + */ + public function testUpdateRejectsInvalidWidgets(array $widgets, string $message): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->expects($this->never())->method('syncPage'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->service->update(7, self::GRID_PAGE, null, null, null, $widgets, 'alice'); + } + + public function testTheStartPageHoldsNoWidgets(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + + $this->expectException(BadRequestError::class); + $this->service->update(7, self::START_PAGE, null, null, null, [], 'alice'); + } + + public function testDeleteRemovesAGridPageWithItsWidgets(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->method('findByPageIds')->willReturn([]); + $this->widgetService->expects($this->once())->method('deleteByPageIds')->with([self::GRID_PAGE]); + $this->pageMapper->expects($this->once())->method('deleteByPageId')->with(self::GRID_PAGE); + + $this->assertSame('Overview', $this->service->delete(7, self::GRID_PAGE, 'alice')['title']); + } + + public function testTheStartPageCannotBeDeleted(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(BadRequestError::class); + $this->service->delete(7, self::START_PAGE, 'alice'); + } + + public function testDeleteNeedsManagePermission(): void { + $this->permissionsService->method('canManageContextById')->willReturn(false); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(PermissionError::class); + $this->service->delete(7, self::GRID_PAGE, 'bob'); + } +} diff --git a/tests/unit/Service/WidgetServiceTest.php b/tests/unit/Service/WidgetServiceTest.php index 94538b914b..b525804753 100644 --- a/tests/unit/Service/WidgetServiceTest.php +++ b/tests/unit/Service/WidgetServiceTest.php @@ -130,6 +130,36 @@ public function testASettingNameIsUniquePerWidget(): void { } } + public function testSyncUpdatesListedInsertsNewAndDeletesMissingWidgets(): void { + $kept = $this->service->insert(self::PAGE, 'header', null, $this->position(0, 0, 12, 2), ['heading' => 'Old', 'subheading' => 'Gone'], null); + $removed = $this->service->insert(self::PAGE, 'data', null, $this->position(0, 2, 12, 5), [], 61); + $other = $this->service->insert(self::OTHER_PAGE, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'Stays'], null); + + $this->service->syncPage(self::PAGE, [ + ['id' => $kept->getId(), 'type' => 'header', 'title' => 'Intro', 'position' => $this->position(0, 0, 6, 3), 'settings' => ['heading' => 'New'], 'nodeRelId' => null], + ['id' => null, 'type' => 'data', 'title' => null, 'position' => $this->position(6, 0, 6, 5), 'settings' => [], 'nodeRelId' => 62], + ]); + + $widgets = $this->service->findByPageIds([self::PAGE, self::OTHER_PAGE]); + $this->assertCount(2, $widgets[self::PAGE]); + [$header, $data] = $widgets[self::PAGE]; + $this->assertSame($kept->getId(), $header['id']); + $this->assertSame(['Intro', 0, 0, 6, 3], [$header['title'], $header['gridX'], $header['gridY'], $header['gridWidth'], $header['gridHeight']]); + $this->assertSame(['heading' => 'New'], $header['settings']); + $this->assertNotSame($removed->getId(), $data['id']); + $this->assertSame(['nodeRelId' => 62], $data['source']); + $this->assertSame([], (new WidgetSourceMapper($this->connectionAdapter))->findNodeRelIdsByWidgetIds([$removed->getId()])); + $this->assertSame($other->getId(), $widgets[self::OTHER_PAGE][0]['id']); + } + + public function testSyncWithNoWidgetsEmptiesThePage(): void { + $this->service->insert(self::PAGE, 'text', null, $this->position(0, 0, 6, 2), ['text' => 'A'], null); + + $this->service->syncPage(self::PAGE, []); + + $this->assertSame([], $this->service->findByPageIds([self::PAGE])); + } + /** * @return array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} */