Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions lib/Controller/ContextPageController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Tables\Controller;

use OCA\Tables\AppInfo\Application;
use OCA\Tables\Errors\BadRequestError;
use OCA\Tables\Errors\NotFoundError;
use OCA\Tables\Errors\PermissionError;
use OCA\Tables\Middleware\Attribute\RequirePermission;
use OCA\Tables\ResponseDefinitions;
use OCA\Tables\Service\GridWidgetTypes;
use OCA\Tables\Service\PageService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\ApiRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\Attribute\UserRateLimit;
use OCP\AppFramework\Http\DataResponse;
use OCP\DB\Exception;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;

/**
* @psalm-import-type TablesPage from ResponseDefinitions
* @psalm-import-type TablesWidgetType from ResponseDefinitions
*/
class ContextPageController extends AOCSController {
public function __construct(
IRequest $request,
LoggerInterface $logger,
IL10N $n,
string $userId,
private readonly PageService $pageService,
private readonly GridWidgetTypes $gridWidgetTypes,
) {
parent::__construct($request, $logger, $n, $userId);
}

/**
* [api v2] Get a page of an application with its widgets
*
* @param int $contextId ID of the application
* @param int $pageId ID of the page
* @return DataResponse<Http::STATUS_OK, TablesPage, array{}>|DataResponse<Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_NOT_FOUND, array{message: string}, array{}>
*
* 200: Page returned
* 404: Not found
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_READ, typeParam: 'context', idParam: 'contextId')]
#[ApiRoute(verb: 'GET', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])]
public function show(int $contextId, int $pageId): DataResponse {
try {
return new DataResponse($this->pageService->find($contextId, $pageId, $this->userId));
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Add a grid page to an application
*
* @param int $contextId ID of the application
* @param string $title Title of the page
* @param string|null $technicalName Technical name of the page, unique in the application; `page_<id>` 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<Http::STATUS_OK, TablesPage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Page created
* 400: Invalid title, icon or description, or an invalid or taken technical name
* 403: No permissions
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 20, period: 60)]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
#[ApiRoute(verb: 'POST', url: '/api/2/contexts/{contextId}/pages', requirements: ['contextId' => '\d+'])]
public function create(int $contextId, string $title, ?string $technicalName = null, ?string $icon = null, ?string $description = null): DataResponse {
try {
return new DataResponse($this->pageService->create($contextId, $title, $technicalName, $icon, $description, $this->userId));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Change a page or its widgets
*
* Fields left out keep their value; an empty icon or description removes it. A new
* technical name must be valid and free in the application. When
* `widgets` is given, the widgets of the grid page are made to match it in one
* transaction: listed widgets with an `id` are updated, listed widgets without one
* are added and widgets that are not listed are deleted.
*
* A widget has a `type`, an optional `title`, `icon` and `description`, `gridX`, `gridY`, `gridWidth` and
* `gridHeight` on a grid of 12 columns and `settings` following the schema of its
* type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or
* view of this application, `{nodeType, nodeId}` of a table or view the user manages,
* which is added to the application, or null. A table or view added this way is also
* placed on the start page, and both stay when the widget is removed later.
*
* A page holds at most 100 widgets. When another save changed the page in between,
* for instance removed a listed widget, the save is refused and can be retried on the
* page as it is now.
*
* @param int $contextId ID of the application
* @param int $pageId ID of the page
* @param string|null $title New title
* @param string|null $technicalName New technical name
* @param string|null $icon New icon name, empty to remove it
* @param string|null $description New description, empty to remove it
* @param list<array<string, mixed>>|null $widgets All widgets of the page
* @return DataResponse<Http::STATUS_OK, TablesPage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Page updated
* 400: Invalid page field or widget, too many widgets, or the page changed in between
* 403: No permissions on the application or on a table or view to add
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[UserRateLimit(limit: 60, period: 60)]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
#[ApiRoute(verb: 'PATCH', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])]
public function update(int $contextId, int $pageId, ?string $title = null, ?string $technicalName = null, ?string $icon = null, ?string $description = null, ?array $widgets = null): DataResponse {
try {
return new DataResponse($this->pageService->update($contextId, $pageId, $title, $technicalName, $icon, $description, $widgets, $this->userId));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e));
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Delete a grid page with its widgets
*
* The start page of an application cannot be deleted.
*
* @param int $contextId ID of the application
* @param int $pageId ID of the page
* @return DataResponse<Http::STATUS_OK, TablesPage, array{}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_FORBIDDEN|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, array{message: string}, array{}>
*
* 200: Page deleted
* 400: The page is the start page
* 403: No permissions
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
#[ApiRoute(verb: 'DELETE', url: '/api/2/contexts/{contextId}/pages/{pageId}', requirements: ['contextId' => '\d+', 'pageId' => '\d+'])]
public function destroy(int $contextId, int $pageId): DataResponse {
try {
return new DataResponse($this->pageService->delete($contextId, $pageId, $this->userId));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Get the widget types a grid page can hold
*
* Every type carries the schema of its settings and whether it shows a table or view.
*
* @return DataResponse<Http::STATUS_OK, list<TablesWidgetType>, 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()));
}
}
16 changes: 16 additions & 0 deletions lib/Db/PageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[]
*/
Expand Down
9 changes: 5 additions & 4 deletions lib/Db/Widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@

namespace OCA\Tables\Db;

use OCA\Tables\ResponseDefinitions;
use OCP\AppFramework\Db\Entity;

/**
* A widget on a grid page: its type, optional title, icon and description, its place
* on the grid and its settings, stored as one JSON object.
*
* @psalm-type TablesWidgetSettingValue = string|int|float|bool|null
* @psalm-import-type TablesWidgetValue from ResponseDefinitions
*
* @method getPageId(): int
* @method setPageId(int $value): void
Expand Down Expand Up @@ -60,22 +61,22 @@ public function __construct() {
}

/**
* @return array<string, TablesWidgetSettingValue>
* @return array<string, TablesWidgetValue>
*/
public function getSettingsArray(): array {
$settings = $this->getSettings();
if ($settings === null || $settings === '') {
return [];
}
$decoded = json_decode($settings, true);
/** @var array<string, TablesWidgetSettingValue> */
/** @var array<string, TablesWidgetValue> */
return is_array($decoded) ? $decoded : [];
}

/**
* Stores the settings as one JSON object, `{}` when there are none.
*
* @param array<string, TablesWidgetSettingValue> $settings
* @param array<string, TablesWidgetValue> $settings
* @throws \JsonException
*/
public function setSettingsArray(array $settings): void {
Expand Down
44 changes: 44 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array{value: string, label: string}>,
* required?: bool,
* }
*
* @psalm-type TablesWidgetType = array{
* type: string,
* title: string,
* defaultWidth: int,
* defaultHeight: int,
* hasSource: bool,
* settings: array<string, TablesWidgetSettingSchema>,
* }
*
* @psalm-type TablesWidget = array{
* id: int,
* type: string,
* title: ?string,
* icon: ?string,
* description: ?string,
* gridX: int,
* gridY: int,
* gridWidth: int,
* gridHeight: int,
* settings: array<string, TablesWidgetValue>|\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<TablesWidget>,
* }
*
* @psalm-type TablesContextNavigation = array{
* id: int,
* shareId: int,
Expand Down
25 changes: 6 additions & 19 deletions lib/Service/GridWidgetTypes.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,18 @@

namespace OCA\Tables\Service;

use OCA\Tables\Db\Widget;
use OCA\Tables\Errors\BadRequestError;
use OCA\Tables\ResponseDefinitions;
use OCP\IL10N;

/**
* The widget types a grid page can hold. Every type has a schema for its settings;
* 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<array{value: string, label: string}>,
* required?: bool,
* }
* @psalm-type TablesWidgetType = array{
* type: string,
* title: string,
* defaultWidth: int,
* defaultHeight: int,
* hasSource: bool,
* settings: array<string, TablesWidgetSettingSchema>,
* }
* @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';
Expand Down Expand Up @@ -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<string, TablesWidgetSettingValue>
* @return array<string, TablesWidgetValue>
* @throws BadRequestError
*/
public function sanitizeSettings(string $type, mixed $settings): array {
Expand All @@ -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 {
Expand Down
Loading
Loading