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
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@
['name' => 'Context#importScheme', 'url' => '/api/2/contexts/{contextId}/scheme/import', 'verb' => 'POST'],
['name' => 'Context#transfer', 'url' => '/api/2/contexts/{contextId}/transfer', 'verb' => 'PUT'],
['name' => 'Context#updateContentOrder', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PUT'],
['name' => 'ContextPage#create', 'url' => '/api/2/contexts/{contextId}/pages', 'verb' => 'POST'],
['name' => 'ContextPage#show', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'GET'],
['name' => 'ContextPage#update', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'PATCH'],
['name' => 'ContextPage#destroy', 'url' => '/api/2/contexts/{contextId}/pages/{pageId}', 'verb' => 'DELETE'],
['name' => 'ContextPage#widgetTypes', 'url' => '/api/2/widget-types', 'verb' => 'GET'],

['name' => 'Config#getTableConfig', 'url' => '/api/2/config/table/{id}', 'verb' => 'GET'],
['name' => 'Config#getViewConfig', 'url' => '/api/2/config/view/{id}', 'verb' => 'GET'],
Expand Down
192 changes: 192 additions & 0 deletions lib/Controller/ContextPageController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?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\NoAdminRequired;
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')]
public function show(int $contextId, int $pageId): DataResponse {
try {
return new DataResponse($this->pageService->find($contextId, $pageId, $this->userId));
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Add a grid page to an application
*
* @param int $contextId ID of the application
* @param string $title Title of the page
* @param string|null $emoji Emoji shown with the title
* @param string|null $description Description of the page
* @return DataResponse<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, emoji or description
* 403: No permissions
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
public function create(int $contextId, string $title, ?string $emoji = null, ?string $description = null): DataResponse {
try {
return new DataResponse($this->pageService->create($contextId, $title, $emoji, $description, $this->userId));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Change a page or its widgets
*
* Fields left out keep their value; an empty emoji or description removes it. When
* `widgets` is given, the widgets of the grid page are made to match it in one
* transaction: listed widgets with an `id` are updated, listed widgets without one
* are added and widgets that are not listed are deleted.
*
* A widget has a `type`, an optional `title`, `gridX`, `gridY`, `gridWidth` and
* `gridHeight` on a grid of 12 columns and `settings` following the schema of its
* type. A widget of a type with a source has a `source`: `{nodeRelId}` of a table or
* view of this application, `{nodeType, nodeId}` of a table or view the user manages,
* which is added to the application, or null.
*
* @param int $contextId ID of the application
* @param int $pageId ID of the page
* @param string|null $title New title
* @param string|null $emoji New emoji, empty to remove it
* @param string|null $description New description, empty to remove it
* @param list<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
* 403: No permissions on the application or on a table or view to add
* 404: Not found
*
* @CanManageContext
*/
#[NoAdminRequired]
#[RequirePermission(permission: Application::PERMISSION_MANAGE, typeParam: 'context', idParam: 'contextId')]
public function update(int $contextId, int $pageId, ?string $title = null, ?string $emoji = null, ?string $description = null, ?array $widgets = null): DataResponse {
try {
return new DataResponse($this->pageService->update($contextId, $pageId, $title, $emoji, $description, $widgets, $this->userId));
} catch (BadRequestError $e) {
return $this->handleBadRequestError($e);
} catch (PermissionError $e) {
return $this->handlePermissionError($e);
} catch (NotFoundError $e) {
return $this->handleNotFoundError($e);
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
return $this->handleNotFoundError(new NotFoundError($e->getMessage(), $e->getCode(), $e));
} catch (Exception $e) {
return $this->handleError($e);
}
}

/**
* [api v2] Delete a grid page with its widgets
*
* The start page of an application cannot be deleted.
*
* @param int $contextId ID of the application
* @param int $pageId ID of the page
* @return DataResponse<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')]
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]
public function widgetTypes(): DataResponse {
return new DataResponse(array_values($this->gridWidgetTypes->all()));
}
}
17 changes: 17 additions & 0 deletions lib/Db/PageMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

namespace OCA\Tables\Db;

use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

Expand All @@ -19,6 +22,20 @@ public function __construct(IDBConnection $db) {
parent::__construct($db, $this->table, Page::class);
}

/**
* @throws DoesNotExistException
* @throws MultipleObjectsReturnedException
* @throws Exception
*/
public function findById(int $pageId): Page {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($this->table)
->where($qb->expr()->eq('id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT)));

return $this->findEntity($qb);
}

/**
* @return int[]
*/
Expand Down
7 changes: 4 additions & 3 deletions lib/Db/WidgetSettingMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace OCA\Tables\Db;

use OCA\Tables\ResponseDefinitions;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
Expand All @@ -18,7 +19,7 @@
* Reads and writes widget settings as name => value maps. Values are stored JSON
* encoded, one row per setting.
*
* @psalm-type TablesWidgetSettingValue = string|int|float|bool|null
* @psalm-import-type TablesWidgetValue from ResponseDefinitions
* @template-extends QBMapper<WidgetSetting>
*/
class WidgetSettingMapper extends QBMapper {
Expand All @@ -30,7 +31,7 @@ public function __construct(IDBConnection $db) {

/**
* @param int[] $widgetIds
* @return array<int, array<string, TablesWidgetSettingValue>> settings by widget id
* @return array<int, array<string, TablesWidgetValue>> settings by widget id
* @throws Exception
*/
public function findSettingsByWidgetIds(array $widgetIds): array {
Expand All @@ -57,7 +58,7 @@ public function findSettingsByWidgetIds(array $widgetIds): array {
/**
* Replaces all settings of a widget.
*
* @param array<string, TablesWidgetSettingValue> $settings
* @param array<string, TablesWidgetValue> $settings
* @throws Exception
* @throws \JsonException
*/
Expand Down
41 changes: 41 additions & 0 deletions lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,47 @@
* ownerType: int,
* }
*
* @psalm-type TablesWidgetValue = string|int|float|bool|null
*
* @psalm-type TablesWidgetSettingSchema = array{
* type: 'string'|'text'|'color'|'boolean'|'enum',
* title: string,
* default: TablesWidgetValue,
* options?: list<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,
* gridX: int,
* gridY: int,
* gridWidth: int,
* gridHeight: int,
* settings: array<string, TablesWidgetValue>,
* source: ?array{nodeRelId: int},
* }
*
* @psalm-type TablesPage = array{
* id: int,
* contextId: int,
* pageType: string,
* title: ?string,
* emoji: ?string,
* description: ?string,
* widgets: list<TablesWidget>,
* }
*
* @psalm-type TablesContextNavigation = array{
* id: int,
* shareId: int,
Expand Down
14 changes: 11 additions & 3 deletions lib/Service/ContextService.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public function __construct(
private TableMapper $tableMapper,
private ViewMapper $viewMapper,
private WidgetService $widgetService,
private GridWidgetTypes $gridWidgetTypes,
) {
}

Expand Down Expand Up @@ -683,7 +684,8 @@ public function getPagesWithWidgets(Context $context): array {

/**
* Inserts an exported widget on a page. Its source is remapped to the imported node
* relation, and dropped when that node was not imported.
* relation, and dropped when that node was not imported. A widget whose type or
* settings are not valid is skipped.
*
* @param array<array-key, ContextNodeRelation> $nodeRelMap imported node relations by their exported id
* @throws Exception
Expand All @@ -693,9 +695,15 @@ private function importWidget(int $pageId, mixed $widgetArr, array $nodeRelMap):
if (!is_array($widgetArr) || !is_string($widgetArr['type'] ?? null)) {
return;
}
$oldNodeRelId = $widgetArr['source']['nodeRelId'] ?? null;
try {
$hasSource = $this->gridWidgetTypes->get($widgetArr['type'])['hasSource'];
$settings = $this->gridWidgetTypes->sanitizeSettings($widgetArr['type'], $widgetArr['settings'] ?? null);
} catch (BadRequestError $e) {
$this->logger->warning('Skipping an imported widget: ' . $e->getMessage(), ['exception' => $e]);
return;
}
$oldNodeRelId = $hasSource ? ($widgetArr['source']['nodeRelId'] ?? null) : null;
$newNodeRel = $oldNodeRelId !== null ? ($nodeRelMap[$oldNodeRelId] ?? null) : null;
$settings = is_array($widgetArr['settings'] ?? null) ? $widgetArr['settings'] : [];
$this->widgetService->insert(
$pageId,
$widgetArr['type'],
Expand Down
Loading
Loading