From db5fa97a0b19cf2c3ab2aab8603b675360b297f2 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 22 Oct 2026 15:10:23 +0200 Subject: [PATCH 1/2] feat(api): add grid pages and widget types to api/2 Read, add, change and delete grid pages with their widgets, and list the widget types with their settings schemas. Reading needs read access to the application, changes need manage access. Page creation is limited to 20 a minute per user. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- lib/Controller/ContextPageController.php | 195 +++++++++ lib/Db/PageMapper.php | 16 + lib/Db/Widget.php | 35 +- lib/ResponseDefinitions.php | 44 ++ lib/Service/ContextService.php | 1 - lib/Service/GridWidgetTypes.php | 25 +- lib/Service/PageService.php | 381 ++++++++++++++++ lib/Service/WidgetService.php | 32 +- .../Controller/ContextPageControllerTest.php | 113 +++++ tests/unit/Db/WidgetMapperTest.php | 2 +- tests/unit/Db/WidgetTest.php | 22 +- tests/unit/Service/PageServiceTest.php | 405 ++++++++++++++++++ tests/unit/Service/WidgetServiceTest.php | 2 +- 13 files changed, 1194 insertions(+), 79 deletions(-) create mode 100644 lib/Controller/ContextPageController.php create mode 100644 lib/Service/PageService.php create mode 100644 tests/unit/Controller/ContextPageControllerTest.php create mode 100644 tests/unit/Service/PageServiceTest.php 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 45dba3831b..8438010ac5 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,36 +56,14 @@ 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); } /** * The settings as an object, so they serialise as a JSON object also when empty. */ public function getSettingsObject(): \stdClass { - return (object)$this->getSettingsArray(); + return (object)($this->getSettings() ?? []); } public function jsonSerialize(): array { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 194aa0a24a..f58ba28fb9 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -237,6 +237,50 @@ * ownerType: int, * } * + * @psalm-type TablesWidgetValue = string|int|float|bool|null + * + * @psalm-type TablesWidgetSettingSchema = array{ + * type: 'string'|'text'|'color'|'boolean'|'enum', + * title: string, + * default: TablesWidgetValue, + * options?: list, + * required?: bool, + * } + * + * @psalm-type TablesWidgetType = array{ + * type: string, + * title: string, + * defaultWidth: int, + * defaultHeight: int, + * hasSource: bool, + * settings: array, + * } + * + * @psalm-type TablesWidget = array{ + * id: int, + * type: string, + * title: ?string, + * icon: ?string, + * description: ?string, + * gridX: int, + * gridY: int, + * gridWidth: int, + * gridHeight: int, + * settings: array|\stdClass, + * source: ?array{nodeRelId: int}, + * } + * + * @psalm-type TablesPage = array{ + * id: int, + * contextId: int, + * pageType: string, + * technicalName: string, + * title: ?string, + * icon: ?string, + * description: ?string, + * widgets: list, + * } + * * @psalm-type TablesContextNavigation = array{ * id: int, * shareId: int, diff --git a/lib/Service/ContextService.php b/lib/Service/ContextService.php index 7edb8aa914..45e24f268d 100644 --- a/lib/Service/ContextService.php +++ b/lib/Service/ContextService.php @@ -776,7 +776,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/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 a4be9cfd28..abcb706153 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' => ['{"text":"Projects","showTitle":false,"level":2}', '{"text":"Projects","showTitle":false,"level":2}'], - '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('{"text":"Projects","showTitle":false}', 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 bc12dc2f79..47112cfeaf 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')); From d87850f4bff4ebd58427912d069b6ccbaabf4521 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Thu, 22 Oct 2026 15:10:23 +0200 Subject: [PATCH 2/2] chore(api): regenerate the OpenAPI spec and TypeScript types Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: Ruben van der Linde --- openapi.json | 1569 +++++++++++++++++++++++++++++++--- src/types/openapi/openapi.ts | 720 ++++++++++++++-- 2 files changed, 2125 insertions(+), 164 deletions(-) diff --git a/openapi.json b/openapi.json index c140ed6423..5b9eaf9d56 100644 --- a/openapi.json +++ b/openapi.json @@ -451,6 +451,53 @@ } } }, + "Page": { + "type": "object", + "required": [ + "id", + "contextId", + "pageType", + "technicalName", + "title", + "icon", + "description", + "widgets" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "contextId": { + "type": "integer", + "format": "int64" + }, + "pageType": { + "type": "string" + }, + "technicalName": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "icon": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Widget" + } + } + } + }, "PublicColumn": { "type": "object", "required": [ @@ -1288,6 +1335,180 @@ "nullable": true } } + }, + "Widget": { + "type": "object", + "required": [ + "id", + "type", + "title", + "icon", + "description", + "gridX", + "gridY", + "gridWidth", + "gridHeight", + "settings", + "source" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "type": { + "type": "string" + }, + "title": { + "type": "string", + "nullable": true + }, + "icon": { + "type": "string", + "nullable": true + }, + "description": { + "type": "string", + "nullable": true + }, + "gridX": { + "type": "integer", + "format": "int64" + }, + "gridY": { + "type": "integer", + "format": "int64" + }, + "gridWidth": { + "type": "integer", + "format": "int64" + }, + "gridHeight": { + "type": "integer", + "format": "int64" + }, + "settings": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/WidgetValue" + } + }, + "source": { + "type": "object", + "nullable": true, + "required": [ + "nodeRelId" + ], + "properties": { + "nodeRelId": { + "type": "integer", + "format": "int64" + } + } + } + } + }, + "WidgetSettingSchema": { + "type": "object", + "required": [ + "type", + "title", + "default" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "string", + "text", + "color", + "boolean", + "enum" + ] + }, + "title": { + "type": "string" + }, + "default": { + "$ref": "#/components/schemas/WidgetValue" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "required": [ + "value", + "label" + ], + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + } + }, + "required": { + "type": "boolean" + } + } + }, + "WidgetType": { + "type": "object", + "required": [ + "type", + "title", + "defaultWidth", + "defaultHeight", + "hasSource", + "settings" + ], + "properties": { + "type": { + "type": "string" + }, + "title": { + "type": "string" + }, + "defaultWidth": { + "type": "integer", + "format": "int64" + }, + "defaultHeight": { + "type": "integer", + "format": "int64" + }, + "hasSource": { + "type": "boolean" + }, + "settings": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/WidgetSettingSchema" + } + } + } + }, + "WidgetValue": { + "nullable": true, + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "boolean" + } + ] } } }, @@ -14287,14 +14508,12 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { + }, "get": { - "operationId": "config-get-table-config", - "summary": "Gets the config for a specific table", + "operationId": "context_page-show", + "summary": "[api v2] Get a page of an application with its widgets", "tags": [ - "config" + "context_page" ], "security": [ { @@ -14306,9 +14525,19 @@ ], "parameters": [ { - "name": "id", + "name": "contextId", "in": "path", - "description": "Table id", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", "required": true, "schema": { "type": "integer", @@ -14328,7 +14557,7 @@ ], "responses": { "200": { - "description": "Table config returned", + "description": "Page returned", "content": { "application/json": { "schema": { @@ -14348,7 +14577,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/NotifyConfig" + "$ref": "#/components/schemas/Page" } } } @@ -14357,8 +14586,8 @@ } } }, - "401": { - "description": "Current user is not logged in", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -14377,57 +14606,26 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } } } } } } } - } - } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/view/{id}": { - "get": { - "operationId": "config-get-view-config", - "summary": "Gets the config for a specific view", - "tags": [ - "config" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "id", - "in": "path", - "description": "View id", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } }, - { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, - "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "View config returned", + "404": { + "description": "Not found, also when the user has no access to the application", "content": { "application/json": { "schema": { @@ -14447,15 +14645,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": { @@ -14485,14 +14691,13 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/config/{key}": { - "post": { - "operationId": "config-set-value", - "summary": "Sets a config value for a specific key", + }, + "patch": { + "operationId": "context_page-update", + "summary": "[api v2] Change a page or its widgets", + "description": "Fields left out keep their value; an empty icon or description removes it. A 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": [ { @@ -14503,18 +14708,47 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "value" - ], "properties": { - "value": { - "type": "object", - "description": "Config value" + "title": { + "type": "string", + "nullable": true, + "default": null, + "description": "New title" + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "New technical name" + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "New icon name, empty to remove it" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "New description, empty to remove it" + }, + "widgets": { + "type": "array", + "nullable": true, + "default": null, + "description": "All widgets of the page", + "items": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } } } } @@ -14523,12 +14757,23 @@ }, "parameters": [ { - "name": "key", + "name": "contextId", "in": "path", - "description": "Config key", + "description": "ID of the application", "required": true, "schema": { - "type": "string" + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" } }, { @@ -14544,7 +14789,7 @@ ], "responses": { "200": { - "description": "Config updated", + "description": "Page updated", "content": { "application/json": { "schema": { @@ -14564,7 +14809,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "boolean" + "$ref": "#/components/schemas/Page" } } } @@ -14574,7 +14819,7 @@ } }, "400": { - "description": "bad request", + "description": "Invalid page field or widget, too many widgets, or the page changed in between", "content": { "application/json": { "schema": { @@ -14649,6 +14894,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": { @@ -14678,18 +14999,15 @@ } } } - } - }, - "/ocs/v2.php/apps/tables/api/2/public/{token}/columns": { - "get": { - "operationId": "api_public_columns-index-by-public-link", - "summary": "[api v2] Get all columns for a table or a view shared by link", - "description": "Return an empty array if no columns were found", + }, + "delete": { + "operationId": "context_page-destroy", + "summary": "[api v2] Delete a grid page with its widgets", + "description": "The start page of an application cannot be deleted.", "tags": [ - "api_public_columns" + "context_page" ], "security": [ - {}, { "bearer_auth": [] }, @@ -14699,13 +15017,23 @@ ], "parameters": [ { - "name": "token", + "name": "contextId", "in": "path", - "description": "The share token", + "description": "ID of the application", "required": true, "schema": { - "type": "string", - "pattern": "^[a-zA-Z0-9]{16}$" + "type": "integer", + "format": "int64" + } + }, + { + "name": "pageId", + "in": "path", + "description": "ID of the page", + "required": true, + "schema": { + "type": "integer", + "format": "int64" } }, { @@ -14721,7 +15049,7 @@ ], "responses": { "200": { - "description": "Columns are returned", + "description": "Page deleted", "content": { "application/json": { "schema": { @@ -14741,10 +15069,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PublicColumn" - } + "$ref": "#/components/schemas/Page" } } } @@ -14753,8 +15078,8 @@ } } }, - "403": { - "description": "No permissions", + "400": { + "description": "The page is the start page", "content": { "application/json": { "schema": { @@ -14791,8 +15116,8 @@ } } }, - "400": { - "description": "Invalid request parameters", + "403": { + "description": "No permissions", "content": { "application/json": { "schema": { @@ -14868,7 +15193,7 @@ } }, "500": { - "description": "Internal error", + "description": "", "content": { "application/json": { "schema": { @@ -14904,6 +15229,1034 @@ } } } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/config/table/{id}": { + "get": { + "operationId": "config-get-table-config", + "summary": "Gets the config for a specific table", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Table id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Table config returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/NotifyConfig" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/config/view/{id}": { + "get": { + "operationId": "config-get-view-config", + "summary": "Gets the config for a specific view", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "View id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "View config returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/NotifyConfig" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/config/{key}": { + "post": { + "operationId": "config-set-value", + "summary": "Sets a config value for a specific key", + "tags": [ + "config" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "object", + "description": "Config value" + } + } + } + } + } + }, + "parameters": [ + { + "name": "key", + "in": "path", + "description": "Config key", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Config updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "400": { + "description": "bad request", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/public/{token}/columns": { + "get": { + "operationId": "api_public_columns-index-by-public-link", + "summary": "[api v2] Get all columns for a table or a view shared by link", + "description": "Return an empty array if no columns were found", + "tags": [ + "api_public_columns" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "token", + "in": "path", + "description": "The share token", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-zA-Z0-9]{16}$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Columns are returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicColumn" + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid request parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages": { + "post": { + "operationId": "context_page-create", + "summary": "[api v2] Add a grid page to an application", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "title": { + "type": "string", + "description": "Title of the page" + }, + "technicalName": { + "type": "string", + "nullable": true, + "default": null, + "description": "Technical name of the page, unique in the application; `page_` when left out, a form reserved for automatic names" + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "Name of the icon shown with the title" + }, + "description": { + "type": "string", + "nullable": true, + "default": null, + "description": "Description of the page" + } + } + } + } + } + }, + "parameters": [ + { + "name": "contextId", + "in": "path", + "description": "ID of the application", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Page created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Page" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid title, icon or description, or an invalid or taken technical name", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "403": { + "description": "No permissions", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/tables/api/2/widget-types": { + "get": { + "operationId": "context_page-widget-types", + "summary": "[api v2] Get the widget types a grid page can hold", + "description": "Every type carries the schema of its settings and whether it shows a table or view.", + "tags": [ + "context_page" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Widget types returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WidgetType" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 2c3d2b29ab..c61530cd13 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -900,14 +900,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}": { @@ -981,6 +991,43 @@ export type paths = { readonly patch?: never; readonly trace?: never; }; + readonly "/ocs/v2.php/apps/tables/api/2/contexts/{contextId}/pages": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + readonly get?: never; + readonly put?: never; + /** [api v2] Add a grid page to an application */ + readonly post: operations["context_page-create"]; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; + readonly "/ocs/v2.php/apps/tables/api/2/widget-types": { + readonly parameters: { + readonly query?: never; + readonly header?: never; + readonly path?: never; + readonly cookie?: never; + }; + /** + * [api v2] Get the widget types a grid page can hold + * @description Every type carries the schema of its settings and whether it shows a table or view. + */ + readonly get: operations["context_page-widget-types"]; + readonly put?: never; + readonly post?: never; + readonly delete?: never; + readonly options?: never; + readonly head?: never; + readonly patch?: never; + readonly trace?: never; + }; readonly "/ocs/v2.php/apps/tables/api/2/public/{token}/rows": { readonly parameters: { readonly query?: never; @@ -1198,6 +1245,18 @@ export type components = { readonly totalitems?: string; readonly itemsperpage?: string; }; + readonly Page: { + /** Format: int64 */ + readonly id: number; + /** Format: int64 */ + readonly contextId: number; + readonly pageType: string; + readonly technicalName: string; + readonly title: string | null; + readonly icon: string | null; + readonly description: string | null; + readonly widgets: readonly components["schemas"]["Widget"][]; + }; readonly PublicColumn: { /** Format: int64 */ readonly id: number; @@ -1425,6 +1484,53 @@ export type components = { /** Format: int64 */ readonly sidebarOrder: number | null; }; + readonly Widget: { + /** Format: int64 */ + readonly id: number; + readonly type: string; + readonly title: string | null; + readonly icon: string | null; + readonly description: string | null; + /** Format: int64 */ + readonly gridX: number; + /** Format: int64 */ + readonly gridY: number; + /** Format: int64 */ + readonly gridWidth: number; + /** Format: int64 */ + readonly gridHeight: number; + readonly settings: { + readonly [key: string]: components["schemas"]["WidgetValue"]; + }; + readonly source: { + /** Format: int64 */ + readonly nodeRelId: number; + } | null; + }; + readonly WidgetSettingSchema: { + /** @enum {string} */ + readonly type: "string" | "text" | "color" | "boolean" | "enum"; + readonly title: string; + readonly default: components["schemas"]["WidgetValue"]; + readonly options?: readonly { + readonly value: string; + readonly label: string; + }[]; + readonly required?: boolean; + }; + readonly WidgetType: { + readonly type: string; + readonly title: string; + /** Format: int64 */ + readonly defaultWidth: number; + /** Format: int64 */ + readonly defaultHeight: number; + readonly hasSource: boolean; + readonly settings: { + readonly [key: string]: components["schemas"]["WidgetSettingSchema"]; + }; + }; + readonly WidgetValue: (string | number | boolean) | null; }; responses: never; parameters: never; @@ -7746,6 +7852,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; @@ -7868,7 +8052,7 @@ export interface operations { }; }; }; - readonly "config-get-table-config": { + readonly "context_page-destroy": { readonly parameters: { readonly query?: never; readonly header: { @@ -7876,14 +8060,16 @@ export interface operations { readonly "OCS-APIRequest": boolean; }; readonly path: { - /** @description Table id */ - readonly id: number; + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; }; readonly cookie?: never; }; readonly requestBody?: never; readonly responses: { - /** @description Table config returned */ + /** @description Page deleted */ readonly 200: { headers: { readonly [name: string]: unknown; @@ -7892,7 +8078,23 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: components["schemas"]["NotifyConfig"]; + readonly data: components["schemas"]["Page"]; + }; + }; + }; + }; + /** @description The page is the start page */ + readonly 400: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; }; }; }; @@ -7911,25 +8113,24 @@ export interface operations { }; }; }; - }; - }; - readonly "config-get-view-config": { - readonly parameters: { - readonly query?: never; - readonly header: { - /** @description Required to be true for the API request to pass */ - readonly "OCS-APIRequest": boolean; - }; - readonly path: { - /** @description View id */ - readonly id: number; + /** @description No permissions */ + readonly 403: { + headers: { + readonly [name: string]: unknown; + }; + content: { + readonly "application/json": { + readonly ocs: { + readonly meta: components["schemas"]["OCSMeta"]; + readonly data: { + readonly message: string; + }; + }; + }; + }; }; - readonly cookie?: never; - }; - readonly requestBody?: never; - readonly responses: { - /** @description View config returned */ - readonly 200: { + /** @description Not found */ + readonly 404: { headers: { readonly [name: string]: unknown; }; @@ -7937,13 +8138,14 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: components["schemas"]["NotifyConfig"]; + readonly data: { + readonly message: string; + }; }; }; }; }; - /** @description Current user is not logged in */ - readonly 401: { + readonly 500: { headers: { readonly [name: string]: unknown; }; @@ -7951,14 +8153,16 @@ export interface operations { readonly "application/json": { readonly ocs: { readonly meta: components["schemas"]["OCSMeta"]; - readonly data: unknown; + readonly data: { + readonly message: string; + }; }; }; }; }; }; }; - readonly "config-set-value": { + readonly "context_page-update": { readonly parameters: { readonly query?: never; readonly header: { @@ -7966,21 +8170,48 @@ export interface operations { readonly "OCS-APIRequest": boolean; }; readonly path: { - /** @description Config key */ - readonly key: string; + /** @description ID of the application */ + readonly contextId: number; + /** @description ID of the page */ + readonly pageId: number; }; readonly cookie?: never; }; - readonly requestBody: { + readonly requestBody?: { readonly content: { readonly "application/json": { - /** @description Config value */ - readonly value: Record; + /** + * @description New title + * @default null + */ + readonly title?: string | null; + /** + * @description New technical name + * @default null + */ + readonly technicalName?: string | null; + /** + * @description New icon name, empty to remove it + * @default null + */ + readonly icon?: string | null; + /** + * @description New description, empty to remove it + * @default null + */ + readonly description?: string | null; + /** + * @description All widgets of the page + * @default null + */ + readonly widgets?: readonly { + readonly [key: string]: Record; + }[] | null; }; }; }; readonly responses: { - /** @description Config updated */ + /** @description Page updated */ readonly 200: { headers: { readonly [name: string]: unknown; @@ -7989,12 +8220,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; @@ -8040,23 +8271,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: { @@ -8137,6 +8573,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?: {