diff --git a/lib/Controller/ContextPageController.php b/lib/Controller/ContextPageController.php new file mode 100644 index 0000000000..5306253099 --- /dev/null +++ b/lib/Controller/ContextPageController.php @@ -0,0 +1,209 @@ +|DataResponse + * + * 200: Page returned + * 404: Not found + */ + #[NoAdminRequired] + #[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')] + #[ApiRoute(verb: 'GET', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])] + 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 $technicalName Technical name of the page, unique in the application; `page_` when left out, a form reserved for automatic names + * @param string|null $icon Name of the icon shown with the title + * @param string|null $description Description of the page + * @return DataResponse|DataResponse + * + * 200: Page created + * 400: Invalid title, icon or description, or an invalid or taken technical name + * 403: No permissions + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 20, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + #[ApiRoute(verb: 'POST', url: '/api/2/contexts/{contextId}/pages', requirements: ['contextId' => '\d+'])] + public function create(int $contextId, string $title, ?string $technicalName = null, ?string $icon = null, ?string $description = null): DataResponse { + try { + return new DataResponse($this->pageService->create($contextId, $title, $technicalName, $icon, $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 icon or description removes it. A new + * technical name must be valid and free in the application. 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`, `icon` and `description`, `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. A table or view added this way is also + * placed on the start page, and both stay when the widget is removed later. + * + * A page holds at most 100 widgets. When another save changed the page in between, + * for instance removed a listed widget, the save is refused and can be retried on the + * page as it is now. + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @param string|null $title New title + * @param string|null $technicalName New technical name + * @param string|null $icon New icon name, 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, too many widgets, or the page changed in between + * 403: No permissions on the application or on a table or view to add + * 404: Not found + * + * @CanManageContext + */ + #[NoAdminRequired] + #[UserRateLimit(limit: 60, period: 60)] + #[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')] + #[ApiRoute(verb: 'PATCH', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])] + public function update(int $contextId, int $pageId, ?string $title = null, ?string $technicalName = null, ?string $icon = null, ?string $description = null, ?array $widgets = null): DataResponse { + try { + return new DataResponse($this->pageService->update($contextId, $pageId, $title, $technicalName, $icon, $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')] + #[ApiRoute(verb: 'DELETE', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])] + 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] + #[ApiRoute(verb: 'GET', url: '/api/2/widget-types')] + 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 cd85c17f94..7ed7d21a5a 100644 --- a/lib/Db/PageMapper.php +++ b/lib/Db/PageMapper.php @@ -7,6 +7,8 @@ namespace OCA\Tables\Db; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\MultipleObjectsReturnedException; use OCP\AppFramework\Db\QBMapper; use OCP\AppFramework\Db\TTransactional; use OCP\DB\Exception; @@ -36,6 +38,20 @@ public function insertWithDefaultName(Page $page): Page { }, $this->db); } + /** + * @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/Widget.php b/lib/Db/Widget.php index 45dba3831b..59a99612a0 100644 --- a/lib/Db/Widget.php +++ b/lib/Db/Widget.php @@ -9,13 +9,14 @@ namespace OCA\Tables\Db; +use OCA\Tables\ResponseDefinitions; use OCP\AppFramework\Db\Entity; /** * A widget on a grid page: its type, optional title, icon and description, its place * on the grid and its settings, stored as one JSON object. * - * @psalm-type TablesWidgetSettingValue = string|int|float|bool|null + * @psalm-import-type TablesWidgetValue from ResponseDefinitions * * @method getPageId(): int * @method setPageId(int $value): void @@ -60,7 +61,7 @@ public function __construct() { } /** - * @return array + * @return array */ public function getSettingsArray(): array { $settings = $this->getSettings(); @@ -68,14 +69,14 @@ public function getSettingsArray(): array { return []; } $decoded = json_decode($settings, true); - /** @var array */ + /** @var array */ return is_array($decoded) ? $decoded : []; } /** * Stores the settings as one JSON object, `{}` when there are none. * - * @param array $settings + * @param array $settings * @throws \JsonException */ public function setSettingsArray(array $settings): void { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 194aa0a24a..f58ba28fb9 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -237,6 +237,50 @@ * 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, + * icon: ?string, + * description: ?string, + * gridX: int, + * gridY: int, + * gridWidth: int, + * gridHeight: int, + * settings: array|\stdClass, + * source: ?array{nodeRelId: int}, + * } + * + * @psalm-type TablesPage = array{ + * id: int, + * contextId: int, + * pageType: string, + * technicalName: string, + * title: ?string, + * icon: ?string, + * description: ?string, + * widgets: list, + * } + * * @psalm-type TablesContextNavigation = array{ * id: int, * shareId: int, diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php index 8864565440..9c8e686be8 100644 --- a/lib/Service/GridWidgetTypes.php +++ b/lib/Service/GridWidgetTypes.php @@ -9,8 +9,8 @@ namespace OCA\Tables\Service; -use OCA\Tables\Db\Widget; use OCA\Tables\Errors\BadRequestError; +use OCA\Tables\ResponseDefinitions; use OCP\IL10N; /** @@ -18,22 +18,9 @@ * the frontend renders its form from it and saved widgets are validated against it. * A type with a source shows a table or view of the application. * - * @psalm-import-type TablesWidgetSettingValue from Widget - * @psalm-type TablesWidgetSettingSchema = array{ - * type: 'string'|'text'|'color'|'boolean'|'enum', - * title: string, - * default: TablesWidgetSettingValue, - * options?: list, - * required?: bool, - * } - * @psalm-type TablesWidgetType = array{ - * type: string, - * title: string, - * defaultWidth: int, - * defaultHeight: int, - * hasSource: bool, - * settings: array, - * } + * @psalm-import-type TablesWidgetValue from ResponseDefinitions + * @psalm-import-type TablesWidgetSettingSchema from ResponseDefinitions + * @psalm-import-type TablesWidgetType from ResponseDefinitions */ class GridWidgetTypes { public const TYPE_HEADER = 'header'; @@ -117,7 +104,7 @@ public function get(string $type): array { * with defaults filled in and unknown settings dropped. Stored as JSON they take at * most {@see self::SETTINGS_MAX_BYTES} bytes. * - * @return array + * @return array * @throws BadRequestError */ public function sanitizeSettings(string $type, mixed $settings): array { @@ -143,7 +130,7 @@ public function sanitizeSettings(string $type, mixed $settings): array { /** * @param TablesWidgetSettingSchema $property - * @return TablesWidgetSettingValue + * @return TablesWidgetValue * @throws BadRequestError */ private function sanitizeValue(string $label, array $property, mixed $value): string|bool|int|null { diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php new file mode 100644 index 0000000000..51eadaf8fc --- /dev/null +++ b/lib/Service/PageService.php @@ -0,0 +1,447 @@ +, nodeRelId: ?int, nodeKey: ?string} + */ +class PageService { + use TTransactional; + + private const CHANGED_IN_BETWEEN = 'The page was changed by another save in the meantime. Load it again and retry.'; + + 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'], + 'technicalName' => (string)$page['technical_name'], + 'title' => $page['title'] ?? null, + 'icon' => $page['icon'] ?? null, + 'description' => $page['description'] ?? null, + 'widgets' => $widgets[$pageId] ?? [], + ]; + } + + /** + * Adds a grid page to an application. Without a technical name the page is named + * `page_`; a given technical name that is taken is refused. + * + * @return TablesPage + * @throws BadRequestError + * @throws NotFoundError + * @throws PermissionError + * @throws Exception + */ + public function create(int $contextId, string $title, ?string $technicalName, ?string $icon, ?string $description, string $userId): array { + $context = $this->getManageableContext($contextId, $userId); + + $page = new Page(); + $page->setContextId($contextId); + $page->setPageType(Page::TYPE_GRID); + $page->setTitle($this->validateTitle($title)); + $page->setIcon($this->validateIcon($icon)); + $page->setDescription($this->validateDescription($description)); + + if ($technicalName !== null) { + $page->setTechnicalName($this->validateTechnicalName($technicalName, $context, null)); + $this->saveNamed(fn () => $this->pageMapper->insert($page), $technicalName); + } else { + $page = $this->pageMapper->insertWithDefaultName($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 icon + * or description removes it. A new technical name must be valid and free in the + * application; the title does not change 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 $technicalName, ?string $icon, ?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 ($technicalName !== null && $technicalName !== $page->getTechnicalName()) { + $page->setTechnicalName($this->validateTechnicalName($technicalName, $context, $pageId)); + } + if ($icon !== null) { + $page->setIcon($this->validateIcon($icon)); + } + 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); + } + + $renamed = array_key_exists('technicalName', $page->getUpdatedFields()); + $this->saveChanges(fn () => $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), $renamed ? (string)$page->getTechnicalName() : null); + + 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; + } + + if (count($widgets) > GridWidgetTypes::MAX_WIDGETS_PER_PAGE) { + throw new BadRequestError('A page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . ' widgets.'); + } + $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->gridWidgetTypes->validateTitle($index, $widget['title'] ?? null), + 'icon' => $this->validateWidgetIcon($index, $widget['icon'] ?? null), + 'description' => $this->gridWidgetTypes->validateDescription($index, $widget['description'] ?? null), + 'position' => $this->gridWidgetTypes->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) > Page::TITLE_MAX_LENGTH) { + throw new BadRequestError('A page title needs 1 to ' . Page::TITLE_MAX_LENGTH . ' characters.'); + } + return $title; + } + + /** + * Runs a save of the fields and widgets of a page. A unique index refusing the save + * means the new technical name was taken; a widget no longer on the page, or another + * refusal of the index, means a concurrent save changed the page in between. + * + * @throws BadRequestError + * @throws Exception + */ + private function saveChanges(callable $save, ?string $newTechnicalName): void { + try { + $save(); + } catch (\InvalidArgumentException $e) { + throw new BadRequestError(self::CHANGED_IN_BETWEEN, 0, $e); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + if ($newTechnicalName !== null) { + throw new BadRequestError('The technical name ' . $newTechnicalName . ' is already used by a page of this application.', 0, $e); + } + throw new BadRequestError(self::CHANGED_IN_BETWEEN, 0, $e); + } + } + + /** + * Runs a save of a page under a technical name that was given, reporting a save the + * unique index refused as a taken name. + * + * @throws BadRequestError + * @throws Exception + */ + private function saveNamed(callable $save, string $technicalName): void { + try { + $save(); + } catch (Exception $e) { + if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + throw $e; + } + throw new BadRequestError('The technical name ' . $technicalName . ' is already used by a page of this application.'); + } + } + + /** + * @throws BadRequestError + */ + private function validateTechnicalName(string $technicalName, Context $context, ?int $pageId): string { + if (!TechnicalNameValidator::isValid($technicalName)) { + throw new BadRequestError('A technical name has 1 to ' . TechnicalNameValidator::MAX_LENGTH . ' lower case letters, digits and underscores and starts with a letter.'); + } + if (Page::isDefaultName($technicalName) && $technicalName !== Page::DEFAULT_NAME_PREFIX . $pageId) { + throw new BadRequestError('The technical name ' . $technicalName . ' is reserved for automatic names.'); + } + foreach ($context->getPages() ?? [] as $id => $page) { + if ((int)$id !== $pageId && ($page['technical_name'] ?? null) === $technicalName) { + throw new BadRequestError('The technical name ' . $technicalName . ' is already used by a page of this application.'); + } + } + return $technicalName; + } + + /** + * A named icon, or null for an empty one. + * + * @throws BadRequestError + */ + private function validateIcon(?string $icon, string $of = 'A page'): ?string { + $icon = trim((string)$icon); + if ($icon === '') { + return null; + } + if (!IconHelper::isValid($icon)) { + throw new BadRequestError($of . ' has an unknown icon: ' . $icon); + } + return $icon; + } + + /** + * @throws BadRequestError + */ + private function validateDescription(?string $description): ?string { + $description = trim((string)$description); + if (mb_strlen($description) > Page::DESCRIPTION_MAX_LENGTH) { + throw new BadRequestError('A page description has at most ' . Page::DESCRIPTION_MAX_LENGTH . ' characters.'); + } + return $description === '' ? null : $description; + } + + /** + * @throws BadRequestError + */ + private function validateWidgetIcon(int $index, mixed $icon): ?string { + if ($icon !== null && !is_string($icon)) { + throw new BadRequestError('The icon of widget ' . $index . ' must be the name of an icon.'); + } + return $this->validateIcon($icon, 'Widget ' . $index); + } + + 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 b989d40281..f77fda12f4 100644 --- a/lib/Service/WidgetService.php +++ b/lib/Service/WidgetService.php @@ -12,14 +12,15 @@ use OCA\Tables\Db\Widget; use OCA\Tables\Db\WidgetMapper; use OCA\Tables\Db\WidgetSourceMapper; +use OCA\Tables\ResponseDefinitions; use OCP\DB\Exception; /** * Stores the widgets of grid pages, settings included, and the node a data widget * shows. Callers check permissions and run writes in a transaction. * - * @psalm-import-type TablesWidgetSettingValue from Widget - * @psalm-type TablesWidgetRecord = array{id: int, type: string, title: ?string, icon: ?string, description: ?string, gridX: int, gridY: int, gridWidth: int, gridHeight: int, settings: \stdClass, source: ?array{nodeRelId: int}} + * @psalm-import-type TablesWidget from ResponseDefinitions + * @psalm-import-type TablesWidgetValue from ResponseDefinitions */ class WidgetService { public function __construct( @@ -30,7 +31,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 { @@ -60,7 +61,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 \InvalidArgumentException when the source is not a node of the application of the page * @throws Exception * @throws \JsonException @@ -72,7 +73,7 @@ public function insert(int $pageId, string $type, ?string $title, array $positio /** * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position - * @param array $settings + * @param array $settings * @throws Exception * @throws \JsonException */ @@ -86,12 +87,20 @@ private function insertWidget(int $pageId, string $type, ?string $title, array $ 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. A widget listed without an icon or description has none. * - * @param list, nodeRelId: ?int}> $widgets + * @param list, nodeRelId: ?int}> $widgets * @throws \InvalidArgumentException when an id is not one of a widget of the page, or a * source is not a node of the application of the page * @throws Exception @@ -178,7 +187,7 @@ private function assertSourcesOfPage(int $pageId, array $nodeRelIds): void { /** * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position - * @param array $settings + * @param array $settings * @throws \JsonException */ private function applyFields(Widget $widget, string $type, ?string $title, array $position, array $settings, ?string $icon, ?string $description): void { diff --git a/openapi.json b/openapi.json index c140ed6423..53f5ab0887 100644 --- a/openapi.json +++ b/openapi.json @@ -451,6 +451,53 @@ } } }, + "Page": { + "type": "object", + "required": [ + "id", + "contextId", + "pageType", + "technicalName", + "title", + "icon", + "description", + "widgets" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "contextId": { + "type": "integer", + "format": "int64" + }, + "pageType": { + "type": "string" + }, + "technicalName": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "icon": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Widget" + } + } + } + }, "PublicColumn": { "type": "object", "required": [ @@ -1288,6 +1335,180 @@ "nullable": true } } + }, + "Widget": { + "type": "object", + "required": [ + "id", + "type", + "title", + "icon", + "description", + "gridX", + "gridY", + "gridWidth", + "gridHeight", + "settings", + "source" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "icon": { + "type": "string", + "nullable": true + }, + "description": { + "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,14 +14508,12 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { + }, "get": { - "operationId": "config-get-table-config", - "summary": "Gets the config for a specific table", + "operationId": "context_page-show", + "summary": "[api v2] Get a page of an application with its widgets", "tags": [ - "config" + "context_page" ], "security": [ { @@ -14306,9 +14525,19 @@ ], "parameters": [ { - "name": "id", + "name": "contextId", "in": "path", - "description": "Table id", + "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", @@ -14328,7 +14557,7 @@ ], "responses": { "200": { - "description": "Table config returned", + "description": "Page returned", "content": { "application/json": { "schema": { @@ -14348,7 +14577,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NotifyConfig" + "$ref": "#/components/schemas/Page" } } } @@ -14357,8 +14586,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -14377,57 +14606,26 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } } } } } } } - } - } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/view/{id}": { - "get": { - "operationId": "config-get-view-config", - "summary": "Gets the config for a specific view", - "tags": [ - "config" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "id", - "in": "path", - "description": "View id", - "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": "View config returned", + "404": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -14447,14 +14645,22 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NotifyConfig" - } - } - } - } - } - } - } + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } }, "401": { "description": "Current user is not logged in", @@ -14485,14 +14691,13 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/{key}": { - "post": { - "operationId": "config-set-value", - "summary": "Sets a config value for a specific key", + }, + "patch": { + "operationId": "context_page-update", + "summary": "[api v2] Change a page or its widgets", + "description": "Fields left out keep their value; an empty icon or description removes it. A new technical name must be valid and free in the application. 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`, `icon` and `description`, `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. A table or view added this way is also placed on the start page, and both stay when the widget is removed later.\nA page holds at most 100 widgets. When another save changed the page in between, for instance removed a listed widget, the save is refused and can be retried on the page as it is now.", "tags": [ - "config" + "context_page" ], "security": [ { @@ -14503,18 +14708,47 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "value" - ], "properties": { - "value": { - "type": "object", - "description": "Config value" + "title": { + "type": "string", + "nullable": true, + "default": null, + "description": "New title" + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "New technical name" + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "New icon name, 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" + } + } } } } @@ -14523,12 +14757,23 @@ }, "parameters": [ { - "name": "key", + "name": "contextId", "in": "path", - "description": "Config key", + "description": "ID of the application", "required": true, "schema": { - "type": "string" + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" } }, { @@ -14544,7 +14789,7 @@ ], "responses": { "200": { - "description": "Config updated", + "description": "Page updated", "content": { "application/json": { "schema": { @@ -14564,7 +14809,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "boolean" + "$ref": "#/components/schemas/Page" } } } @@ -14574,7 +14819,7 @@ } }, "400": { - "description": "bad request", + "description": "Invalid page field or widget, too many widgets, or the page changed in between", "content": { "application/json": { "schema": { @@ -14612,7 +14857,83 @@ } }, "403": { - "description": "No permissions", + "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": { @@ -14678,18 +14999,15 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/public/{token}/columns": { - "get": { - "operationId": "api_public_columns-index-by-public-link", - "summary": "[api v2] Get all columns for a table or a view shared by link", - "description": "Return an empty array if no columns were found", + }, + "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": [ - "api_public_columns" + "context_page" ], "security": [ - {}, { "bearer_auth": [] }, @@ -14699,13 +15017,23 @@ ], "parameters": [ { - "name": "token", + "name": "contextId", "in": "path", - "description": "The share token", + "description": "ID of the application", "required": true, "schema": { - "type": "string", - "pattern": "^[a-zA-Z0-9]{16}$" + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" } }, { @@ -14721,7 +15049,7 @@ ], "responses": { "200": { - "description": "Columns are returned", + "description": "Page deleted", "content": { "application/json": { "schema": { @@ -14741,10 +15069,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicColumn" - } + "$ref": "#/components/schemas/Page" } } } @@ -14753,8 +15078,8 @@ } } }, - "403": { - "description": "No permissions", + "400": { + "description": "The page is the start page", "content": { "application/json": { "schema": { @@ -14791,8 +15116,8 @@ } } }, - "400": { - "description": "Invalid request parameters", + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -14868,7 +15193,7 @@ } }, "500": { - "description": "Internal error", + "description": "", "content": { "application/json": { "schema": { @@ -14904,6 +15229,1034 @@ } } } + }, + "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}": { + "get": { + "operationId": "config-get-table-config", + "summary": "Gets the config for a specific table", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Table id", + "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": "Table config 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/NotifyConfig" + } + } + } + } + } + } + } + }, + "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/view/{id}": { + "get": { + "operationId": "config-get-view-config", + "summary": "Gets the config for a specific view", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "View id", + "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": "View config 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/NotifyConfig" + } + } + } + } + } + } + } + }, + "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/{key}": { + "post": { + "operationId": "config-set-value", + "summary": "Sets a config value for a specific key", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "object", + "description": "Config value" + } + } + } + } + } + }, + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Config key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "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": "Config updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "400": { + "description": "bad request", + "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" + } + } + } + } + } + } + } + } + } + }, + "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/public/{token}/columns": { + "get": { + "operationId": "api_public_columns-index-by-public-link", + "summary": "[api v2] Get all columns for a table or a view shared by link", + "description": "Return an empty array if no columns were found", + "tags": [ + "api_public_columns" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "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": "Columns are 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/PublicColumn" + } + } + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "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": "Internal error", + "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" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/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" + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "Technical name of the page, unique in the application; `page_` when left out, a form reserved for automatic names" + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "Name of the icon 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, icon or description, or an invalid or taken technical name", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/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": {} + } + } + } + } + } + } } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2c3d2b29ab..aa641b49f9 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -900,14 +900,25 @@ 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; - readonly delete?: 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; - readonly patch?: never; + /** + * [api v2] Change a page or its widgets + * @description Fields left out keep their value; an empty icon or description removes it. A new technical name must be valid and free in the application. 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`, `icon` and `description`, `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. A table or view added this way is also placed on the start page, and both stay when the widget is removed later. + * A page holds at most 100 widgets. When another save changed the page in between, for instance removed a listed widget, the save is refused and can be retried on the page as it is now. + */ + readonly patch: operations["context_page-update"]; readonly trace?: never; }; readonly "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { @@ -981,6 +992,43 @@ export type paths = { readonly patch?: never; 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; + readonly patch?: never; + readonly trace?: never; + }; readonly "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { readonly parameters: { readonly query?: never; @@ -1198,6 +1246,18 @@ 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 technicalName: string; + readonly title: string | null; + readonly icon: string | null; + readonly description: string | null; + readonly widgets: readonly components["schemas"]["Widget"][]; + }; readonly PublicColumn: { /** Format: int64 */ readonly id: number; @@ -1425,6 +1485,53 @@ export type components = { /** Format: int64 */ readonly sidebarOrder: number | null; }; + readonly Widget: { + /** Format: int64 */ + readonly id: number; + readonly type: string; + readonly title: string | null; + readonly icon: string | null; + readonly description: 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 +7853,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,7 +8053,7 @@ export interface operations { }; }; }; - readonly "config-get-table-config": { + readonly "context_page-destroy": { readonly parameters: { readonly query?: never; readonly header: { @@ -7876,14 +8061,16 @@ export interface operations { readonly "OCS-APIRequest": boolean; }; readonly path: { - /** @description Table id */ - readonly id: number; + /** @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 Table config returned */ + /** @description Page deleted */ readonly 200: { headers: { readonly [name: string]: unknown; @@ -7892,7 +8079,23 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: components["schemas"]["NotifyConfig"]; + 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; + }; }; }; }; @@ -7911,25 +8114,24 @@ export interface operations { }; }; }; - }; - }; - readonly "config-get-view-config": { - 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 View id */ - readonly id: number; + /** @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; + }; + }; + }; + }; }; - readonly cookie?: never; - }; - readonly requestBody?: never; - readonly responses: { - /** @description View config returned */ - readonly 200: { + /** @description Not found */ + readonly 404: { headers: { readonly [name: string]: unknown; }; @@ -7937,13 +8139,14 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: components["schemas"]["NotifyConfig"]; + readonly data: { + readonly message: string; + }; }; }; }; }; - /** @description Current user is not logged in */ - readonly 401: { + readonly 500: { headers: { readonly [name: string]: unknown; }; @@ -7951,14 +8154,16 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: unknown; + readonly data: { + readonly message: string; + }; }; }; }; }; }; }; - readonly "config-set-value": { + readonly "context_page-update": { readonly parameters: { readonly query?: never; readonly header: { @@ -7966,21 +8171,48 @@ export interface operations { readonly "OCS-APIRequest": boolean; }; readonly path: { - /** @description Config key */ - readonly key: string; + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; }; readonly cookie?: never; }; - readonly requestBody: { + readonly requestBody?: { readonly content: { readonly "application/json": { - /** @description Config value */ - readonly value: Record; + /** + * @description New title + * @default null + */ + readonly title?: string | null; + /** + * @description New technical name + * @default null + */ + readonly technicalName?: string | null; + /** + * @description New icon name, empty to remove it + * @default null + */ + readonly icon?: 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 Config updated */ + /** @description Page updated */ readonly 200: { headers: { readonly [name: string]: unknown; @@ -7989,12 +8221,12 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: boolean; + readonly data: components["schemas"]["Page"]; }; }; }; }; - /** @description bad request */ + /** @description Invalid page field or widget, too many widgets, or the page changed in between */ readonly 400: { headers: { readonly [name: string]: unknown; @@ -8024,7 +8256,7 @@ export interface operations { }; }; }; - /** @description No permissions */ + /** @description No permissions on the application or on a table or view to add */ readonly 403: { headers: { readonly [name: string]: unknown; @@ -8040,21 +8272,226 @@ export interface operations { }; }; }; - }; - }; - readonly "api_public_columns-index-by-public-link": { - 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 The share token */ - readonly token: string; - }; - readonly cookie?: never; - }; + /** @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 "config-get-table-config": { + 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 Table id */ + readonly id: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description Table config returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["NotifyConfig"]; + }; + }; + }; + }; + /** @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-view-config": { + 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 View id */ + readonly id: number; + }; + readonly cookie?: never; + }; + readonly requestBody?: never; + readonly responses: { + /** @description View config returned */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: components["schemas"]["NotifyConfig"]; + }; + }; + }; + }; + /** @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-set-value": { + 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 Config key */ + readonly key: string; + }; + readonly cookie?: never; + }; + readonly requestBody: { + readonly content: { + readonly "application/json": { + /** @description Config value */ + readonly value: Record; + }; + }; + }; + readonly responses: { + /** @description Config updated */ + readonly 200: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: boolean; + }; + }; + }; + }; + /** @description bad request */ + 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; + }; + }; + }; + }; + }; + }; + }; + readonly "api_public_columns-index-by-public-link": { + 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 The share token */ + readonly token: string; + }; + readonly cookie?: never; + }; readonly requestBody?: never; readonly responses: { /** @description Columns are returned */ @@ -8137,6 +8574,178 @@ export interface operations { }; }; }; + 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 Technical name of the page, unique in the application; `page_` when left out, a form reserved for automatic names + * @default null + */ + readonly technicalName?: string | null; + /** + * @description Name of the icon shown with the title + * @default null + */ + readonly icon?: 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, icon or description, or an invalid or taken technical name */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Current user is not logged in */ + readonly 401: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: unknown; + }; + }; + }; + }; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + /** @description Not found */ + readonly 404: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + readonly 500: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; + }; + }; + }; + readonly "context_page-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 "public_rowocs-get-rows": { readonly parameters: { readonly query?: { diff --git a/tests/unit/Controller/ContextPageControllerTest.php b/tests/unit/Controller/ContextPageControllerTest.php new file mode 100644 index 0000000000..8f8d5ae558 --- /dev/null +++ b/tests/unit/Controller/ContextPageControllerTest.php @@ -0,0 +1,154 @@ +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', 'technicalName' => 'overview', 'title' => 'Overview', 'icon' => 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', 'overview', 'star', 'At a glance', 'alice')->willReturn([]); + + $this->assertSame(Http::STATUS_OK, $this->controller->create(7, 'Overview', 'overview', 'star', '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, 'planning', '', null, $widgets, 'alice')->willReturn([]); + + $this->assertSame(Http::STATUS_OK, $this->controller->update(7, 2, null, 'planning', '', 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, 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 testCreatingPagesIsRateLimitedPerUser(): void { + $attributes = (new \ReflectionMethod(ContextPageController::class, 'create'))->getAttributes(UserRateLimit::class); + + $this->assertCount(1, $attributes); + $this->assertSame(['limit' => 20, 'period' => 60], $attributes[0]->getArguments()); + } + + public function testWidgetTypesListsEveryType(): void { + $response = $this->controller->widgetTypes(); + + $this->assertSame(['header', 'text', 'data'], array_column($response->getData(), 'type')); + } + + public function testChangingPagesIsRateLimitedPerUser(): void { + $attributes = (new \ReflectionMethod(ContextPageController::class, 'update'))->getAttributes(UserRateLimit::class); + + $this->assertCount(1, $attributes); + $this->assertSame(['limit' => 60, 'period' => 60], $attributes[0]->getArguments()); + } + + public function testEveryEndpointDeclaresItsRoute(): void { + $routes = []; + foreach (['create', 'show', 'update', 'destroy', 'widgetTypes'] as $method) { + $attributes = (new \ReflectionMethod(ContextPageController::class, $method))->getAttributes(ApiRoute::class); + $this->assertCount(1, $attributes, $method); + $routes[$method] = $attributes[0]->getArguments()['verb'] . ' ' . $attributes[0]->getArguments()['url']; + } + + $this->assertSame([ + 'create' => 'POST /api/2/contexts/{contextId}/pages', + 'show' => 'GET /api/2/contexts/{contextId}/pages/{pageId}', + 'update' => 'PATCH /api/2/contexts/{contextId}/pages/{pageId}', + 'destroy' => 'DELETE /api/2/contexts/{contextId}/pages/{pageId}', + 'widgetTypes' => 'GET /api/2/widget-types', + ], $routes); + } +} diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php new file mode 100644 index 0000000000..18bd67c27a --- /dev/null +++ b/tests/unit/Service/PageServiceTest.php @@ -0,0 +1,523 @@ +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, 'technical_name' => 'dashboard', 'title' => null, 'icon' => null, 'description' => null, 'content' => []], + self::GRID_PAGE => ['id' => self::GRID_PAGE, 'page_type' => Page::TYPE_GRID, 'technical_name' => 'overview', 'title' => 'Overview', 'icon' => 'star', '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, 'icon' => null, 'description' => 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, + 'technicalName' => 'overview', + 'title' => 'Overview', + 'icon' => 'star', + '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 testANonMemberFindsNoApplication(): void { + $this->permissionsService->expects($this->never())->method('canManageContextById'); + $this->pageMapper->expects($this->never())->method('insert'); + $this->pageMapper->expects($this->never())->method('update'); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + + foreach ([ + fn () => $this->service->find(8, self::GRID_PAGE, 'mallory'), + fn () => $this->service->create(8, 'Overview', null, null, null, 'mallory'), + fn () => $this->service->update(8, self::GRID_PAGE, 'Mine', null, null, null, [], 'mallory'), + fn () => $this->service->delete(8, self::GRID_PAGE, 'mallory'), + ] as $index => $call) { + try { + $call(); + $this->fail('Call ' . $index . ' did not report the application as not found.'); + } catch (NotFoundError) { + } + } + } + + public function testCreateInsertsAGridPageNamedByItsId(): void { + $this->permissionsService->method('canManageContextById')->with(7, 'alice')->willReturn(true); + $inserted = null; + $this->pageMapper->expects($this->never())->method('insert'); + $this->pageMapper->expects($this->once())->method('insertWithDefaultName')->willReturnCallback(function (Page $page) use (&$inserted): Page { + $page->id = self::GRID_PAGE; + $page->setTechnicalName('page_' . self::GRID_PAGE); + $inserted = $page; + return $page; + }); + + $this->service->create(7, ' Team Overview ', null, ' home ', '', 'alice'); + + $this->assertSame([7, Page::TYPE_GRID, 'Team Overview', 'home', null], [$inserted->getContextId(), $inserted->getPageType(), $inserted->getTitle(), $inserted->getIcon(), $inserted->getDescription()]); + } + + public function testCreateKeepsAGivenTechnicalName(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('insertWithDefaultName'); + $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', 'team_board', null, null, 'alice'); + + $this->assertSame('team_board', $inserted->getTechnicalName()); + } + + public static function refusedTechnicalNames(): array { + return [ + 'upper case' => ['Board', 'lower case letters'], + 'leading digit' => ['2026_plan', 'lower case letters'], + 'dash' => ['team-board', 'lower case letters'], + 'empty' => ['', 'lower case letters'], + 'too long' => [str_repeat('a', 201), 'lower case letters'], + 'taken' => ['dashboard', 'already used'], + 'reserved' => ['page_9', 'reserved for automatic names'], + ]; + } + + /** + * @dataProvider refusedTechnicalNames + */ + public function testCreateRefusesAnInvalidOrTakenTechnicalName(string $technicalName, string $message): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('insert'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->service->create(7, 'Overview', $technicalName, null, null, 'alice'); + } + + public function testCreateRefusesAGivenNameAConcurrentSaveTook(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->once())->method('insert') + ->willThrowException(DbErrorStub::uniqueViolation()); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('already used'); + $this->service->create(7, 'Overview', 'team_board', null, null, 'alice'); + } + + public function testCreateRefusesAnUnknownIcon(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('insert'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('unknown icon'); + $this->service->create(7, 'Overview', null, 'no-such-icon', null, 'alice'); + } + + 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, 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, 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, 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', 'icon' => null, 'description' => null, '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, 'icon' => null, 'description' => null, 'position' => ['gridX' => 0, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], 'settings' => [], 'nodeRelId' => self::NODE_REL], + ['id' => null, 'type' => 'data', 'title' => null, 'icon' => null, 'description' => null, 'position' => ['gridX' => 6, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5], 'settings' => [], 'nodeRelId' => self::NODE_REL], + ['id' => null, 'type' => 'data', 'title' => null, 'icon' => null, 'description' => 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, 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, 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, null, [], 'bob'); + } + + public function testUpdateChangesPageFields(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = new Page(); + $stored->id = self::GRID_PAGE; + $stored->setTechnicalName('overview'); + $stored->setTitle('Overview'); + $stored->setIcon('star'); + $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', 'planning', '', 'What is next', null, 'alice'); + + $this->assertSame([self::GRID_PAGE, 'planning', 'Planning', null, 'What is next'], [$updated->getId(), $updated->getTechnicalName(), $updated->getTitle(), $updated->getIcon(), $updated->getDescription()]); + $this->assertSame(['title', 'technicalName', 'icon', 'description'], array_keys($updated->getUpdatedFields())); + } + + public function testUpdateKeepsTheTechnicalNameWhenTheTitleChanges(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = $this->storedGridPage(); + $this->pageMapper->method('findById')->willReturn($stored); + $this->pageMapper->expects($this->once())->method('update')->willReturnArgument(0); + + $this->service->update(7, self::GRID_PAGE, 'Planning', null, null, null, null, 'alice'); + + $this->assertSame('overview', $stored->getTechnicalName()); + } + + public function testUpdateAcceptsThePageItsOwnTechnicalName(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->method('findById')->willReturn($this->storedGridPage()); + $this->pageMapper->expects($this->never())->method('update'); + + $this->service->update(7, self::GRID_PAGE, null, 'overview', null, null, null, 'alice'); + } + + public function testUpdateAcceptsTheAutomaticNameOfThePageItself(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $stored = $this->storedGridPage(); + $this->pageMapper->method('findById')->willReturn($stored); + $this->pageMapper->expects($this->once())->method('update')->willReturnArgument(0); + + $this->service->update(7, self::GRID_PAGE, null, 'page_' . self::GRID_PAGE, null, null, null, 'alice'); + + $this->assertSame('page_' . self::GRID_PAGE, $stored->getTechnicalName()); + } + + /** + * @dataProvider refusedTechnicalNames + */ + public function testUpdateRefusesAnInvalidOrTakenTechnicalName(string $technicalName, string $message): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->method('findById')->willReturn($this->storedGridPage()); + $this->pageMapper->expects($this->never())->method('update'); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->service->update(7, self::GRID_PAGE, null, $technicalName, null, null, null, 'alice'); + } + + public function testUpdateRefusesANameAConcurrentSaveTook(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->method('findById')->willReturn($this->storedGridPage()); + $this->pageMapper->method('update') + ->willThrowException(DbErrorStub::uniqueViolation()); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('already used'); + $this->service->update(7, self::GRID_PAGE, null, 'planning', null, null, null, 'alice'); + } + + public function testUpdateRefusesAnUnknownIcon(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->method('findById')->willReturn($this->storedGridPage()); + $this->pageMapper->expects($this->never())->method('update'); + + $this->expectException(BadRequestError::class); + $this->service->update(7, self::GRID_PAGE, null, null, 'no-such-icon', null, null, 'alice'); + } + + public function testUpdatePassesWidgetIconsAndDescriptions(): 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 { + $synced = $rows; + }); + + $this->service->update(7, self::GRID_PAGE, null, null, null, null, [ + ['type' => 'text', 'icon' => 'star', 'description' => ' Notes ', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2], + ['type' => 'text', 'icon' => '', 'description' => '', 'gridX' => 6, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2], + ], 'alice'); + + $this->assertSame([['star', 'Notes'], [null, null]], array_map(static fn (array $row): array => [$row['icon'], $row['description']], $synced)); + } + + private function storedGridPage(): Page { + $stored = new Page(); + $stored->id = self::GRID_PAGE; + $stored->setContextId(7); + $stored->setTechnicalName('overview'); + $stored->setTitle('Overview'); + $stored->resetUpdatedFields(); + return $stored; + } + + 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'], + 'unknown icon' => [[['type' => 'text', 'icon' => 'no-such-icon'] + $at], 'Widget 0 has an unknown icon'], + 'icon not text' => [[['type' => 'text', 'icon' => 5] + $at], 'The icon of widget 0'], + 'description not text' => [[['type' => 'text', 'description' => ['x']] + $at], 'The description of widget 0'], + 'description too long' => [[['type' => 'text', 'description' => str_repeat('a', 2001)] + $at], 'The description 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, 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, 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'); + } + + public function testUpdateRejectsAPageOfAnotherApplication(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->expects($this->never())->method('findById'); + $this->pageMapper->expects($this->never())->method('update'); + $this->widgetService->expects($this->never())->method('syncPage'); + + $this->expectException(NotFoundError::class); + $this->service->update(7, 99, 'Taken over', null, null, null, [], 'alice'); + } + + public function testDeleteRejectsAPageOfAnotherApplication(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->expects($this->never())->method('deleteByPageIds'); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(NotFoundError::class); + $this->service->delete(7, 99, 'alice'); + } + + public function testUpdateRefusesMoreWidgetsThanAPageHolds(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->expects($this->never())->method('syncPage'); + $widgets = []; + for ($i = 0; $i <= GridWidgetTypes::MAX_WIDGETS_PER_PAGE; $i++) { + $widgets[] = ['type' => 'text', 'gridX' => 0, 'gridY' => $i * 2, 'gridWidth' => 6, 'gridHeight' => 2]; + } + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('at most 100 widgets'); + $this->service->update(7, self::GRID_PAGE, null, null, null, null, $widgets, 'alice'); + } + + public function testUpdateAcceptsAsManyWidgetsAsAPageHolds(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->expects($this->once())->method('syncPage'); + $widgets = []; + for ($i = 0; $i < GridWidgetTypes::MAX_WIDGETS_PER_PAGE; $i++) { + $widgets[] = ['type' => 'text', 'gridX' => 0, 'gridY' => $i * 2, 'gridWidth' => 6, 'gridHeight' => 2]; + } + + $this->service->update(7, self::GRID_PAGE, null, null, null, null, $widgets, 'alice'); + } + + /** + * A concurrent save removed a listed widget between the check and the sync. + */ + public function testAWidgetRemovedByAConcurrentSaveAsksForARetry(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->widgetService->method('syncPage')->willThrowException(new \InvalidArgumentException('Widget 31 is not on page 2')); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('changed by another save'); + $this->service->update(7, self::GRID_PAGE, null, null, null, null, [['id' => 31, 'type' => 'text', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]], 'alice'); + } + + /** + * Two saves adding a source to the same widget at once collide on the source key; that + * is no taken technical name. + */ + public function testAConcurrentCollisionWithoutARenameAsksForARetry(): void { + $this->permissionsService->method('canManageContextById')->willReturn(true); + $this->pageMapper->method('findById')->willReturn($this->storedGridPage()); + $this->widgetService->method('syncPage')->willThrowException(DbErrorStub::uniqueViolation()); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('changed by another save'); + $this->service->update(7, self::GRID_PAGE, 'Renamed title', 'overview', null, null, [['id' => 31, 'type' => 'text', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]], 'alice'); + } +}