Repository navigation
feat(pages): fill the start page of an application with widgets #3211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,8 +105,9 @@ public function create(int $contextId, string $title, ?string $technicalName = n | |
| * [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. | ||
| * `widgets` list replaces the widgets of the page, a grid page or the start page: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that the start page holds widgets, the descriptions around this one still say 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`, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\Tables\Migration; | ||
|
|
||
| use Closure; | ||
| use OCA\Tables\Db\Page; | ||
| use OCA\Tables\Service\GridWidgetTypes; | ||
| use OCP\DB\QueryBuilder\IQueryBuilder; | ||
| use OCP\IDBConnection; | ||
| use OCP\Migration\IOutput; | ||
| use OCP\Migration\SimpleMigrationStep; | ||
| use Override; | ||
|
|
||
| /** | ||
| * The start page of an application is a grid page. Every start page without widgets is | ||
| * filled the way a new one is: a header with the name and description of its application, | ||
| * then one table or view widget per table or view, in the order the start page listed them. | ||
| * A start page that has widgets is left alone, so a second run changes nothing. | ||
| */ | ||
| class Version2500Date20261030000000 extends SimpleMigrationStep { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things about where this step sits in the stack. It needs the widget tables that And the fill happens at the upgrade to this PR, while only #3216 shows it; the description invites shipping this one earlier. Everything users add or reorder through Edit application in between only reaches |
||
| private const PREFIX = 'tables_contexts_'; | ||
|
|
||
| public function __construct( | ||
| private readonly IDBConnection $db, | ||
| ) { | ||
| } | ||
|
|
||
| #[Override] | ||
| public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { | ||
| foreach ($this->emptyStartPages() as $startPage) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AGENTS.md: "Never build a One SELECT for the content of all start pages ( |
||
| $widgets = GridWidgetTypes::startPageWidgets( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Related to #3131 (comment), one step further: besides
|
||
| (string)$startPage['name'], | ||
| (string)($startPage['description'] ?? ''), | ||
| $this->nodeRelIdsInPageOrder((int)$startPage['page_id']), | ||
| ); | ||
| $this->db->beginTransaction(); | ||
| try { | ||
| foreach ($widgets as $widget) { | ||
| $this->insertWidget((int)$startPage['page_id'], $widget); | ||
| } | ||
| $this->db->commit(); | ||
| } catch (\Throwable $e) { | ||
| $this->db->rollBack(); | ||
| throw $e; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @return list<array<string, mixed>> start pages without widgets, with the name and description of their application | ||
| */ | ||
| private function emptyStartPages(): array { | ||
| $qb = $this->db->getQueryBuilder(); | ||
| $qb->select('p.id as page_id', 'c.name', 'c.description') | ||
| ->from(self::PREFIX . 'page', 'p') | ||
| ->innerJoin('p', self::PREFIX . 'context', 'c', $qb->expr()->eq('c.id', 'p.context_id')) | ||
| ->leftJoin('p', self::PREFIX . 'widget', 'w', $qb->expr()->eq('w.page_id', 'p.id')) | ||
| ->where($qb->expr()->eq('p.page_type', $qb->createNamedParameter(Page::TYPE_STARTPAGE))) | ||
| ->andWhere($qb->expr()->isNull('w.id')) | ||
| ->orderBy('p.id'); | ||
| $result = $qb->executeQuery(); | ||
| $rows = $result->fetchAllAssociative(); | ||
| $result->closeCursor(); | ||
| return $rows; | ||
| } | ||
|
|
||
| /** | ||
| * @return list<int> the tables and views a start page lists, as node relations of its application, in page order | ||
| */ | ||
| private function nodeRelIdsInPageOrder(int $pageId): array { | ||
| $qb = $this->db->getQueryBuilder(); | ||
| $qb->select('pc.node_rel_id') | ||
| ->from(self::PREFIX . 'page_content', 'pc') | ||
| ->innerJoin('pc', self::PREFIX . 'rel_context_node', 'r', $qb->expr()->eq('r.id', 'pc.node_rel_id')) | ||
| ->where($qb->expr()->eq('pc.page_id', $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT))) | ||
| ->orderBy('pc.order') | ||
| ->addOrderBy('pc.id'); | ||
| $result = $qb->executeQuery(); | ||
| $nodeRelIds = array_map(intval(...), $result->fetchFirstColumn()); | ||
| $result->closeCursor(); | ||
| return array_values(array_unique($nodeRelIds)); | ||
| } | ||
|
|
||
| /** | ||
| * @param array{type: string, position: array{gridX: int, gridY: int, gridWidth: int, gridHeight: int}, settings: array<string, mixed>, nodeRelId: ?int} $widget | ||
| */ | ||
| private function insertWidget(int $pageId, array $widget): void { | ||
| $qb = $this->db->getQueryBuilder(); | ||
| $qb->insert(self::PREFIX . 'widget') | ||
| ->values([ | ||
| 'page_id' => $qb->createNamedParameter($pageId, IQueryBuilder::PARAM_INT), | ||
| 'type' => $qb->createNamedParameter($widget['type']), | ||
| 'grid_x' => $qb->createNamedParameter($widget['position']['gridX'], IQueryBuilder::PARAM_INT), | ||
| 'grid_y' => $qb->createNamedParameter($widget['position']['gridY'], IQueryBuilder::PARAM_INT), | ||
| 'grid_width' => $qb->createNamedParameter($widget['position']['gridWidth'], IQueryBuilder::PARAM_INT), | ||
| 'grid_height' => $qb->createNamedParameter($widget['position']['gridHeight'], IQueryBuilder::PARAM_INT), | ||
| 'settings' => $qb->createNamedParameter($widget['settings'], IQueryBuilder::PARAM_JSON), | ||
| ]) | ||
| ->executeStatement(); | ||
| if ($widget['nodeRelId'] === null) { | ||
| return; | ||
| } | ||
| $widgetId = $qb->getLastInsertId(); | ||
| $source = $this->db->getQueryBuilder(); | ||
| $source->insert(self::PREFIX . 'widget_source') | ||
| ->values([ | ||
| 'widget_id' => $source->createNamedParameter($widgetId, IQueryBuilder::PARAM_INT), | ||
| 'node_rel_id' => $source->createNamedParameter($widget['nodeRelId'], IQueryBuilder::PARAM_INT), | ||
| ]) | ||
| ->executeStatement(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -562,6 +562,7 @@ protected function insertPage(Context $context): void { | |
| $addedPage = $page->jsonSerialize(); | ||
|
|
||
| $i = 1; | ||
| $nodeRelIds = []; | ||
| $contextNodes = $context->getNodes(); | ||
| if ($contextNodes) { | ||
| foreach ($contextNodes as $node) { | ||
|
|
@@ -575,9 +576,14 @@ protected function insertPage(Context $context): void { | |
| $addedPage['content'][$pageContent->getId()] = $pageContent->jsonSerialize(); | ||
| // the content is already embedded in the page | ||
| unset($addedPage['content'][$pageContent->getId()]['pageId']); | ||
| $nodeRelIds[] = (int)$node['id']; | ||
| } | ||
| } | ||
|
|
||
| foreach (GridWidgetTypes::startPageWidgets($context->getName(), $context->getDescription(), $nodeRelIds) as $widget) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only Reproduced on the dev instance: an application created with two tables got a header and two data widgets. The description accepts that tables added later aren't placed, but with Edit application and Import scheme unchanged that means the start page stops matching the application right after it is created, and the same URL now has a PATCH for its widgets and a PUT for an order nothing shows. Putting the rule back where every addition passes, |
||
| $this->widgetService->insert($page->getId(), $widget['type'], null, $widget['position'], $widget['settings'], $widget['nodeRelId']); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Every new application now pays the per-widget pattern from #3132 (comment): The loop is copied into |
||
| } | ||
|
|
||
| $context->setPages([$addedPage['id'] => $addedPage]); | ||
| } | ||
|
|
||
|
|
@@ -718,6 +724,7 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C | |
| $pageMap[$pageArr['id'] ?? null] = $page; | ||
|
|
||
| // insert page conten | ||
| $contentRelIds = []; | ||
| if (!empty($pageArr['content'])) { | ||
| foreach ($pageArr['content'] as $contentArr) { | ||
| $oldNodeRelId = self::importedKey($contentArr['node_rel_id'] ?? $contentArr['nodeRelId'] ?? null); | ||
|
|
@@ -730,6 +737,14 @@ public function importContext(Table $table, array $context, int $tableOldId): ?C | |
| $pageContent->setNodeRelId($newNodeRel->getId()); | ||
| $pageContent->setOrder($contentArr['order'] ?? 100); | ||
| $this->pageContentMapper->insert($pageContent); | ||
| $contentRelIds[] = $newNodeRel->getId(); | ||
| } | ||
| } | ||
|
|
||
| // A start page exported before pages held widgets starts filled, like a new one | ||
| if ($page->getPageType() === Page::TYPE_STARTPAGE && !array_key_exists('widgets', $pageArr)) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A missing Merging #3132 to this PR without a tag in between avoids it. Otherwise, bumping the migrator version (TablesMigrator uses the default from |
||
| foreach (GridWidgetTypes::startPageWidgets($newContext->getName(), $newContext->getDescription(), array_values(array_unique($contentRelIds))) as $widget) { | ||
| $this->widgetService->insert($page->getId(), $widget['type'], null, $widget['position'], $widget['settings'], $widget['nodeRelId']); | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,8 @@ class GridWidgetTypes { | |
| public const TYPE_DATA = 'data'; | ||
|
|
||
| public const GRID_COLUMNS = 12; | ||
| public const HEADER_HEIGHT = 2; | ||
| public const DATA_HEIGHT = 5; | ||
| public const GRID_MAX_HEIGHT = 100; | ||
| public const GRID_MAX_Y = 10000; | ||
| public const TITLE_MAX_LENGTH = 200; | ||
|
|
@@ -51,8 +53,8 @@ public function all(): array { | |
| self::TYPE_HEADER => [ | ||
| 'type' => self::TYPE_HEADER, | ||
| 'title' => $this->l10n->t('Header'), | ||
| 'defaultWidth' => 12, | ||
| 'defaultHeight' => 2, | ||
| 'defaultWidth' => self::GRID_COLUMNS, | ||
| 'defaultHeight' => self::HEADER_HEIGHT, | ||
| 'hasSource' => false, | ||
| 'settings' => [ | ||
| 'heading' => ['type' => 'string', 'title' => $this->l10n->t('Heading'), 'default' => '', 'required' => true], | ||
|
|
@@ -79,14 +81,46 @@ public function all(): array { | |
| self::TYPE_DATA => [ | ||
| 'type' => self::TYPE_DATA, | ||
| 'title' => $this->l10n->t('Table or view'), | ||
| 'defaultWidth' => 12, | ||
| 'defaultHeight' => 5, | ||
| 'defaultWidth' => self::GRID_COLUMNS, | ||
| 'defaultHeight' => self::DATA_HEIGHT, | ||
| 'hasSource' => true, | ||
| 'settings' => [], | ||
| ], | ||
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * The widgets a start page starts with: a header with the name and description of its | ||
| * application, then one full width table or view widget per node, in the given order. | ||
| * Without a name there is no header. | ||
| * | ||
| * @param list<int> $nodeRelIds node relations of the application, in page order | ||
| * @return list<array{type: string, position: array{gridX: int, gridY: int, gridWidth: int, gridHeight: int}, settings: array<string, TablesWidgetValue>, nodeRelId: ?int}> | ||
| */ | ||
| public static function startPageWidgets(string $name, string $description, array $nodeRelIds): array { | ||
| $widgets = []; | ||
| $gridY = 0; | ||
| if (trim($name) !== '') { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Accessibility: the generated header repeats what the page around the start page already shows. In #3216, #3216 keeps both on purpose, because the header is an ordinary widget people can change or remove, and offers to drop the name above the page bar instead. But the header is a copy that nothing keeps current: Filling the start page with its tables and views only keeps it the way it looks today; the data widgets already work that way, showing their table's live title instead of a copy. A header stays one "Add widget" away for owners who want one. If a generated header stays, it needs to follow the application, for example a header setting that shows the application's name instead of storing it. |
||
| $widgets[] = [ | ||
| 'type' => self::TYPE_HEADER, | ||
| 'position' => ['gridX' => 0, 'gridY' => $gridY, 'gridWidth' => self::GRID_COLUMNS, 'gridHeight' => self::HEADER_HEIGHT], | ||
| 'settings' => ['heading' => $name, 'subheading' => $description, 'textAlign' => 'start', 'backgroundColor' => '', 'textColor' => ''], | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing checks these settings against the header schema: Reproduced on the dev instance: The literal also repeats the schema's defaults for |
||
| 'nodeRelId' => null, | ||
| ]; | ||
| $gridY += self::HEADER_HEIGHT; | ||
| } | ||
| foreach (array_slice($nodeRelIds, 0, self::MAX_WIDGETS_PER_PAGE - count($widgets)) as $nodeRelId) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing says when this cuts: an application with more than 99 tables and views (nothing limits that) gets data widgets for the first 99 only, at creation, on upgrade and on import, while |
||
| $widgets[] = [ | ||
| 'type' => self::TYPE_DATA, | ||
| 'position' => ['gridX' => 0, 'gridY' => $gridY, 'gridWidth' => self::GRID_COLUMNS, 'gridHeight' => self::DATA_HEIGHT], | ||
| 'settings' => [], | ||
| 'nodeRelId' => $nodeRelId, | ||
| ]; | ||
| $gridY += self::DATA_HEIGHT; | ||
| } | ||
| return $widgets; | ||
| } | ||
|
|
||
| /** | ||
| * @return TablesWidgetType | ||
| * @throws BadRequestError | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -120,7 +120,7 @@ public function create(int $contextId, string $title, ?string $technicalName, ?s | |
| */ | ||
| 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); | ||
| $this->getPageArray($context, $pageId); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With its result discarded this reads like a leftover, but it is the only check that |
||
|
|
||
| $page = $this->pageMapper->findById($pageId); | ||
| if ($title !== null) { | ||
|
|
@@ -138,9 +138,6 @@ public function update(int $contextId, int $pageId, ?string $title, ?string $tec | |
|
|
||
| $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); | ||
| } | ||
|
|
||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
#3135, on the same base, makes the same bump from
2.5.0-dev.0to2.5.0-dev.1and ships its own step (Version2500Date20261103000000). The two hunks are identical, so they merge without a conflict and whichever PR lands second leaves the version as it is. Nextcloud only upgrades an app when the info.xml version differs frominstalled_version, so on every instance that already took the first one (dev instances tracking main) the second one's step stays pending until the next bump: if #3135 goes first, existing start pages stay unfilled there; in the other order, the menu endpoints run against a missingtables_contexts_menu_item. Same mechanism as #3131 (comment) with a different pair: whichever lands second needs to bump past what its base has by then, and the PRs stacked on it (#3212 is atdev.2) one higher each.