diff --git a/lib/Controller/ContextPageController.php b/lib/Controller/ContextPageController.php new file mode 100644 index 0000000000..ce30cad2bf --- /dev/null +++ b/lib/Controller/ContextPageController.php @@ -0,0 +1,195 @@ +|DataResponse + * + * 200: Page returned + * 404: Not found, also when the user has no access to the application + */ + #[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 + */ + #[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 given + * `widgets` list replaces the widgets of the grid page: listed widgets with an `id` + * are updated, others are added, and widgets not listed are deleted. + * + * A widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of + * 12 columns, `settings` following the schema of its type, and optionally an `id`, + * `title`, `icon` and `description`. A data widget's `source` is `{nodeRelId}` of a + * table or view of this application, or null. + * + * @param int $contextId ID of the application + * @param int $pageId ID of the page + * @param string|null $title New title + * @param string|null $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 + * 404: Not found + */ + #[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 + */ + #[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 8a0caa3fbd..654928d96d 100644 --- a/lib/Db/Widget.php +++ b/lib/Db/Widget.php @@ -10,13 +10,12 @@ namespace OCA\Tables\Db; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * 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 - * * @method getPageId(): int * @method setPageId(int $value): void * @method getType(): string @@ -35,8 +34,8 @@ * @method setGridWidth(int $value): void * @method getGridHeight(): int * @method setGridHeight(int $value): void - * @method getSettings(): ?string - * @method setSettings(?string $value): void + * @method getSettings(): ?array + * @method setSettings(array $value): void */ class Widget extends Entity implements \JsonSerializable { protected ?int $pageId = null; @@ -48,7 +47,7 @@ class Widget extends Entity implements \JsonSerializable { protected ?int $gridY = null; protected ?int $gridWidth = null; protected ?int $gridHeight = null; - protected ?string $settings = null; + protected ?array $settings = null; public function __construct() { $this->addType('id', 'integer'); @@ -57,29 +56,7 @@ public function __construct() { $this->addType('gridY', 'integer'); $this->addType('gridWidth', 'integer'); $this->addType('gridHeight', 'integer'); - } - - /** - * @return array - */ - public function getSettingsArray(): array { - $settings = $this->getSettings(); - if ($settings === null || $settings === '') { - return []; - } - $decoded = json_decode($settings, true); - /** @var array */ - return is_array($decoded) ? $decoded : []; - } - - /** - * Stores the settings as one JSON object, `{}` when there are none. - * - * @param array $settings - * @throws \JsonException - */ - public function setSettingsArray(array $settings): void { - $this->setSettings(json_encode((object)$settings, JSON_THROW_ON_ERROR)); + $this->addType('settings', Types::JSON); } /** @@ -87,7 +64,7 @@ public function setSettingsArray(array $settings): void { * ordered by name: a JSON column does not keep the stored order on every database. */ public function getSettingsObject(): \stdClass { - $settings = $this->getSettingsArray(); + $settings = $this->getSettings() ?? []; ksort($settings); return (object)$settings; } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index dd41a0bf4f..d750e9a969 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -243,6 +243,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/ContextService.php b/lib/Service/ContextService.php index c7f92434af..ed01048c24 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -798,7 +798,6 @@ public function getPagesWithWidgets(Context $context): array { * * @param array $nodeRelMap imported node relations of the table by their exported id * @throws Exception - * @throws \JsonException */ private function importWidget(int $pageId, int $index, mixed $widgetArr, array $nodeRelMap): void { if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) { diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php index 7e0feae85f..152b172272 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..5005b5efed --- /dev/null +++ b/lib/Service/PageService.php @@ -0,0 +1,381 @@ +, nodeRelId: ?int} + */ +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 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 widget's `source` is `{nodeRelId}` of a table or view + * of this 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; + if ($widgets !== null) { + if ($pageArray['page_type'] !== Page::TYPE_GRID) { + throw new BadRequestError('Only grid pages hold widgets.'); + } + $rows = $this->readWidgets($widgets, $context, $pageId); + } + + $renamed = array_key_exists('technicalName', $page->getUpdatedFields()); + $this->saveChanges(fn () => $this->atomic(function () use ($page, $rows, $pageId): void { + if ($page->getUpdatedFields() !== []) { + $this->pageMapper->update($page); + } + if ($rows !== null) { + $this->widgetService->syncPage($pageId, $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->canManageContext($context, $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 list + * @throws BadRequestError + * @throws Exception + */ + private function readWidgets(array $widgets, Context $context, int $pageId): array { + if (count($widgets) > GridWidgetTypes::MAX_WIDGETS_PER_PAGE) { + throw new BadRequestError('A page holds at most ' . GridWidgetTypes::MAX_WIDGETS_PER_PAGE . ' widgets.'); + } + $existingIds = array_flip($this->widgetService->findIdsByPageId($pageId)); + $rows = []; + $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; + } + + $source = $widget['source'] ?? null; + if ($source !== null && !$typeSchema['hasSource']) { + throw new BadRequestError('Widget ' . $index . ' is a ' . $type . ' widget, which shows no table or view.'); + } + + $rows[] = [ + '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' => $source === null ? null : $this->sourceOf($index, $source, $context), + ]; + } + return $rows; + } + + /** + * The node relation a data widget shows, which must be one of the application. + * + * @throws BadRequestError + */ + private function sourceOf(int $index, mixed $source, Context $context): int { + $nodeRelId = is_array($source) ? ($source['nodeRelId'] ?? null) : null; + if (!is_int($nodeRelId) || !isset(($context->getNodes() ?? [])[$nodeRelId])) { + throw new BadRequestError('The source of widget ' . $index . ' must be {nodeRelId} of a table or view of this application.'); + } + return $nodeRelId; + } + + /** + * @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); + } +} diff --git a/lib/Service/WidgetService.php b/lib/Service/WidgetService.php index b6d3711d8a..3abcbb0d37 100644 --- a/lib/Service/WidgetService.php +++ b/lib/Service/WidgetService.php @@ -12,6 +12,8 @@ use OCA\Tables\Db\Widget; use OCA\Tables\Db\WidgetMapper; use OCA\Tables\Db\WidgetSourceMapper; +use OCA\Tables\ResponseDefinitions; +use OCP\AppFramework\Db\TTransactional; use OCP\DB\Exception; use OCP\IDBConnection; @@ -19,11 +21,11 @@ * 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 { - use \OCP\AppFramework\Db\TTransactional; + use TTransactional; public function __construct( private readonly WidgetMapper $widgetMapper, @@ -34,7 +36,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 { @@ -64,10 +66,9 @@ 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 */ public function insert(int $pageId, string $type, ?string $title, array $position, array $settings, ?int $nodeRelId, ?string $icon = null, ?string $description = null): Widget { $this->assertSourcesOfPage($pageId, [$nodeRelId]); @@ -76,9 +77,8 @@ 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 */ private function insertWidget(int $pageId, string $type, ?string $title, array $position, array $settings, ?int $nodeRelId, ?string $icon, ?string $description): Widget { $widget = new Widget(); @@ -90,16 +90,23 @@ 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, in one transaction. 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 - * @throws \JsonException */ public function syncPage(int $pageId, array $widgets): void { $this->atomic(function () use ($pageId, $widgets): void { @@ -184,8 +191,7 @@ private function assertSourcesOfPage(int $pageId, array $nodeRelIds): void { /** * @param array{gridX: int, gridY: int, gridWidth: int, gridHeight: int} $position - * @param array $settings - * @throws \JsonException + * @param array $settings */ private function applyFields(Widget $widget, string $type, ?string $title, array $position, array $settings, ?string $icon, ?string $description): void { $widget->setType($type); @@ -196,6 +202,6 @@ private function applyFields(Widget $widget, string $type, ?string $title, array $widget->setGridY($position['gridY']); $widget->setGridWidth($position['gridWidth']); $widget->setGridHeight($position['gridHeight']); - $widget->setSettingsArray($settings); + $widget->setSettings($settings); } } diff --git a/openapi.json b/openapi.json index 9fd3ae317c..b0e4a2c6cd 100644 --- a/openapi.json +++ b/openapi.json @@ -463,6 +463,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": [ @@ -1312,6 +1359,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" + } + ] } } }, @@ -14571,14 +14792,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": [ { @@ -14590,9 +14809,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", @@ -14612,7 +14841,7 @@ ], "responses": { "200": { - "description": "Table config returned", + "description": "Page returned", "content": { "application/json": { "schema": { @@ -14632,7 +14861,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NotifyConfig" + "$ref": "#/components/schemas/Page" } } } @@ -14641,8 +14870,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -14661,57 +14890,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, also when the user has no access to the application", "content": { "application/json": { "schema": { @@ -14731,15 +14929,23 @@ "$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", "content": { @@ -14769,14 +14975,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 given `widgets` list replaces the widgets of the grid page: listed widgets with an `id` are updated, others are added, and widgets not listed are deleted.\nA widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns, `settings` following the schema of its type, and optionally an `id`, `title`, `icon` and `description`. A data widget's `source` is `{nodeRelId}` of a table or view of this application, or null.", "tags": [ - "config" + "context_page" ], "security": [ { @@ -14787,18 +14992,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" + } + } } } } @@ -14807,12 +15041,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" } }, { @@ -14828,7 +15073,7 @@ ], "responses": { "200": { - "description": "Config updated", + "description": "Page updated", "content": { "application/json": { "schema": { @@ -14848,7 +15093,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "boolean" + "$ref": "#/components/schemas/Page" } } } @@ -14858,7 +15103,7 @@ } }, "400": { - "description": "bad request", + "description": "Invalid page field or widget, too many widgets, or the page changed in between", "content": { "application/json": { "schema": { @@ -14933,6 +15178,82 @@ } } }, + "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": { @@ -14962,18 +15283,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": [] }, @@ -14983,13 +15301,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" } }, { @@ -15005,7 +15333,7 @@ ], "responses": { "200": { - "description": "Columns are returned", + "description": "Page deleted", "content": { "application/json": { "schema": { @@ -15025,10 +15353,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicColumn" - } + "$ref": "#/components/schemas/Page" } } } @@ -15037,8 +15362,8 @@ } } }, - "403": { - "description": "No permissions", + "400": { + "description": "The page is the start page", "content": { "application/json": { "schema": { @@ -15075,8 +15400,8 @@ } } }, - "400": { - "description": "Invalid request parameters", + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -15152,7 +15477,7 @@ } }, "500": { - "description": "Internal error", + "description": "", "content": { "application/json": { "schema": { @@ -15188,6 +15513,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 85437d7fca..301ff9a878 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -940,14 +940,24 @@ 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 given `widgets` list replaces the widgets of the grid page: listed widgets with an `id` are updated, others are added, and widgets not listed are deleted. + * A widget has a `type`, `gridX`, `gridY`, `gridWidth` and `gridHeight` on a grid of 12 columns, `settings` following the schema of its type, and optionally an `id`, `title`, `icon` and `description`. A data widget's `source` is `{nodeRelId}` of a table or view of this application, or null. + */ + readonly patch: operations["context_page-update"]; readonly trace?: never; }; readonly "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { @@ -1021,6 +1031,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; @@ -1244,6 +1291,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; @@ -1477,6 +1536,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; @@ -7946,6 +8052,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, also when the user has no access to the application */ + 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; @@ -8068,7 +8252,7 @@ export interface operations { }; }; }; - readonly "config-get-table-config": { + readonly "context_page-destroy": { readonly parameters: { readonly query?: never; readonly header: { @@ -8076,14 +8260,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; @@ -8092,7 +8278,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; + }; }; }; }; @@ -8111,25 +8313,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; }; @@ -8137,13 +8338,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; }; @@ -8151,14 +8353,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: { @@ -8166,21 +8370,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; @@ -8189,12 +8420,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; @@ -8240,23 +8471,228 @@ 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; - }; - readonly requestBody?: never; - readonly responses: { + /** @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 */ readonly 200: { headers: { @@ -8337,6 +8773,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..664221046c --- /dev/null +++ b/tests/unit/Controller/ContextPageControllerTest.php @@ -0,0 +1,113 @@ +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), + ); + } + + /** + * @return array, 2: \Closure(ContextPageController): DataResponse}> + */ + public static function endpoints(): array { + $widgets = [['type' => 'text', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]]; + return [ + 'show' => ['find', [7, 2, 'alice'], static fn (ContextPageController $controller) => $controller->show(7, 2)], + 'create' => ['create', [7, 'Overview', 'overview', 'star', 'At a glance', 'alice'], static fn (ContextPageController $controller) => $controller->create(7, 'Overview', 'overview', 'star', 'At a glance')], + 'update' => ['update', [7, 2, null, 'planning', '', null, $widgets, 'alice'], static fn (ContextPageController $controller) => $controller->update(7, 2, null, 'planning', '', null, $widgets)], + 'destroy' => ['delete', [7, 2, 'alice'], static fn (ContextPageController $controller) => $controller->destroy(7, 2)], + ]; + } + + /** + * @dataProvider endpoints + */ + public function testAnEndpointReturnsThePage(string $method, array $arguments, \Closure $call): void { + $page = ['id' => 2, 'contextId' => 7, 'pageType' => 'grid', 'technicalName' => 'overview', 'title' => 'Overview', 'icon' => null, 'description' => null, 'widgets' => []]; + $this->pageService->expects($this->once())->method($method)->with(...$arguments)->willReturn($page); + + $response = $call($this->controller); + + $this->assertSame([Http::STATUS_OK, $page], [$response->getStatus(), $response->getData()]); + } + + public static function errors(): array { + $endpoints = self::endpoints(); + $cases = [ + 'show' => [new NotFoundError('Page 9 does not belong to application 7'), Http::STATUS_NOT_FOUND], + ]; + foreach (['create', 'update', 'destroy'] as $endpoint) { + $cases[$endpoint . ', bad request'] = [new BadRequestError('Widget 0 needs a type.'), Http::STATUS_BAD_REQUEST]; + $cases[$endpoint . ', no permission'] = [new PermissionError('User bob cannot manage application 7'), Http::STATUS_FORBIDDEN]; + $cases[$endpoint . ', not found'] = [new NotFoundError('Context does not exist'), Http::STATUS_NOT_FOUND]; + } + $cases['update, page gone'] = [new DoesNotExistException('Page does not exist'), Http::STATUS_NOT_FOUND]; + $errors = []; + foreach ($cases as $case => [$error, $status]) { + [$method, , $call] = $endpoints[explode(',', $case)[0]]; + $errors[$case] = [$method, $call, $error, $status]; + } + return $errors; + } + + /** + * @dataProvider errors + */ + public function testAnErrorIsAnsweredWithItsStatus(string $method, \Closure $call, \Exception $error, int $status): void { + $this->pageService->method($method)->willThrowException($error); + + $response = $call($this->controller); + + $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 function testWidgetTypesListsEveryType(): void { + $response = $this->controller->widgetTypes(); + + $this->assertSame(['header', 'text', 'data'], array_column($response->getData(), 'type')); + } +} diff --git a/tests/unit/Db/WidgetMapperTest.php b/tests/unit/Db/WidgetMapperTest.php index 75e0219e17..0a7240b8b8 100644 --- a/tests/unit/Db/WidgetMapperTest.php +++ b/tests/unit/Db/WidgetMapperTest.php @@ -117,7 +117,7 @@ private function widget(int $pageId, string $type, ?string $title, int $x, int $ $widget->setGridY($y); $widget->setGridWidth($width); $widget->setGridHeight($height); - $widget->setSettingsArray($settings); + $widget->setSettings($settings); return $widget; } } diff --git a/tests/unit/Db/WidgetTest.php b/tests/unit/Db/WidgetTest.php index 449702439c..69b77ae5d9 100644 --- a/tests/unit/Db/WidgetTest.php +++ b/tests/unit/Db/WidgetTest.php @@ -13,22 +13,14 @@ use PHPUnit\Framework\TestCase; class WidgetTest extends TestCase { - public static function storedSettings(): array { - return [ - 'an object, ordered by name' => ['{"text":"Projects","showTitle":false,"level":2}', '{"level":2,"showTitle":false,"text":"Projects"}'], - 'no settings' => [null, '{}'], - 'not an object' => ['"text"', '{}'], - 'broken' => ['{broken', '{}'], - ]; - } - - /** - * @dataProvider storedSettings - */ - public function testSettingsSerialiseAsAJsonObject(?string $stored, string $serialised): void { + public function testSettingsSerialiseAsAJsonObjectAlsoWhenEmpty(): void { $widget = new Widget(); - $widget->setSettings($stored); + $this->assertSame('{}', json_encode($widget->jsonSerialize()['settings'])); + + $widget->setSettings([]); + $this->assertSame('{}', json_encode($widget->jsonSerialize()['settings'])); - $this->assertSame($serialised, json_encode($widget->jsonSerialize()['settings'])); + $widget->setSettings(['text' => 'Projects', 'showTitle' => false]); + $this->assertSame('{"showTitle":false,"text":"Projects"}', json_encode($widget->jsonSerialize()['settings'])); } } diff --git a/tests/unit/Service/PageServiceTest.php b/tests/unit/Service/PageServiceTest.php new file mode 100644 index 0000000000..00774bcaf0 --- /dev/null +++ b/tests/unit/Service/PageServiceTest.php @@ -0,0 +1,405 @@ +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->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->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 testAnApplicationOrPageOutsideTheUsersReachIsNotFound(): void { + $this->permissionsService->method('canManageContext')->willReturn(true); + $this->pageMapper->expects($this->never())->method('insert'); + $this->pageMapper->expects($this->never())->method('update'); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + $this->widgetService->expects($this->never())->method('syncPage'); + + foreach ([ + 'application of no access' => fn () => $this->service->find(8, self::GRID_PAGE, 'mallory'), + 'create in it' => fn () => $this->service->create(8, 'Overview', null, null, null, 'mallory'), + 'update in it' => fn () => $this->service->update(8, self::GRID_PAGE, 'Mine', null, null, null, [], 'mallory'), + 'delete in it' => fn () => $this->service->delete(8, self::GRID_PAGE, 'mallory'), + 'page of another application' => fn () => $this->service->find(7, 99, 'alice'), + 'update it' => fn () => $this->service->update(7, 99, 'Taken over', null, null, null, [], 'alice'), + 'delete it' => fn () => $this->service->delete(7, 99, 'alice'), + ] as $case => $call) { + try { + $call(); + $this->fail($case . ' was found.'); + } catch (NotFoundError) { + } + } + } + + public function testCreateInsertsAGridPageNamedByItsId(): void { + $this->permissionsService->method('canManageContext')->with($this->context, '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('canManageContext')->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 function testCreateRefusesAGivenNameAConcurrentSaveTook(): void { + $this->permissionsService->method('canManageContext')->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 testCreateNeedsATitle(): void { + $this->permissionsService->method('canManageContext')->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('canManageContext')->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', 'icon' => 'star', 'description' => ' Open tasks ', 'gridX' => 0, 'gridY' => 2, 'gridWidth' => 6, 'gridHeight' => 5, 'source' => ['nodeRelId' => self::NODE_REL]], + ['type' => 'data', 'icon' => '', 'description' => '', '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' => 'start', 'backgroundColor' => '', 'textColor' => ''], 'nodeRelId' => null], + ['id' => null, 'type' => 'data', 'title' => null, 'icon' => 'star', 'description' => 'Open tasks', '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' => 0, 'gridY' => 7, 'gridWidth' => 12, 'gridHeight' => 5], 'settings' => [], 'nodeRelId' => null], + ], $synced); + } + + public function testChangesNeedManagePermission(): void { + $this->permissionsService->method('canManageContext')->with($this->context, 'bob')->willReturn(false); + $this->pageMapper->expects($this->never())->method('insert'); + $this->pageMapper->expects($this->never())->method('update'); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + $this->widgetService->expects($this->never())->method('syncPage'); + + foreach ([ + 'create' => fn () => $this->service->create(7, 'Overview', null, null, null, 'bob'), + 'update' => fn () => $this->service->update(7, self::GRID_PAGE, 'New', null, null, null, [], 'bob'), + 'delete' => fn () => $this->service->delete(7, self::GRID_PAGE, 'bob'), + ] as $case => $call) { + try { + $call(); + $this->fail($case . ' was allowed.'); + } catch (PermissionError) { + } + } + } + + public function testUpdateChangesPageFields(): void { + $this->permissionsService->method('canManageContext')->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('canManageContext')->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 testAPageKeepsOrTakesItsOwnNames(): void { + $this->permissionsService->method('canManageContext')->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, 'overview', null, null, null, 'alice'); + $this->service->update(7, self::GRID_PAGE, null, 'page_' . self::GRID_PAGE, null, null, null, 'alice'); + + $this->assertSame('page_' . self::GRID_PAGE, $stored->getTechnicalName()); + } + + public static function refusedTechnicalNames(): array { + return [ + 'upper case' => ['Board', 'lower case letters'], + 'leading digit' => ['2026_plan', '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 testUpdateRefusesAnInvalidOrTakenTechnicalName(string $technicalName, string $message): void { + $this->permissionsService->method('canManageContext')->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('canManageContext')->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('canManageContext')->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'); + } + + 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]; + $foreignSource = 'must be {nodeRelId} of a table or view of this application'; + 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'], + 'invalid title' => [[['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'], + '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], $foreignSource], + 'table outside the application' => [[['type' => 'data', 'source' => ['nodeType' => Application::NODE_TYPE_TABLE, 'nodeId' => 3]] + $at], $foreignSource], + 'source not an object' => [[['type' => 'data', 'source' => self::NODE_REL] + $at], $foreignSource], + ]; + } + + /** + * @dataProvider invalidWidgets + */ + public function testUpdateRejectsInvalidWidgets(array $widgets, string $message): void { + $this->permissionsService->method('canManageContext')->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('canManageContext')->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('canManageContext')->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('canManageContext')->willReturn(true); + $this->pageMapper->expects($this->never())->method('deleteByPageId'); + + $this->expectException(BadRequestError::class); + $this->service->delete(7, self::START_PAGE, 'alice'); + } + + public function testAPageHoldsAtMostTheMaximumNumberOfWidgets(): void { + $this->permissionsService->method('canManageContext')->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'); + + $widgets[] = ['type' => 'text', 'gridX' => 6, 'gridY' => 0, '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'); + } + + /** + * A concurrent save removed a listed widget between the check and the sync. + */ + public function testAWidgetRemovedByAConcurrentSaveAsksForARetry(): void { + $this->permissionsService->method('canManageContext')->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('canManageContext')->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'); + } +} diff --git a/tests/unit/Service/WidgetServiceTest.php b/tests/unit/Service/WidgetServiceTest.php index fbc977d5f4..d4bb51bb7f 100644 --- a/tests/unit/Service/WidgetServiceTest.php +++ b/tests/unit/Service/WidgetServiceTest.php @@ -188,7 +188,7 @@ public function testAFailedSyncChangesNothing(): void { ['id' => null, 'type' => 'text', 'title' => null, 'position' => $this->position(0, 0, 6, 2), 'settings' => ['text' => NAN], 'nodeRelId' => null], ]); $this->fail('Settings that are no JSON were saved.'); - } catch (\JsonException) { + } catch (\OCP\DB\Exception) { } $this->assertSame([$kept->getId()], array_column($this->service->findByPageIds([$this->page])[$this->page], 'id'));