diff --git a/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index 1317289a30..6d25eb91ab 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -19,6 +19,7 @@ enum ViewUpdatableParameters: string { case TECHNICAL_NAME = 'technicalName'; case SIDEBAR_ORDER = 'sidebarOrder'; case TYPE = 'type'; + case GRID = 'grid'; case LAYOUT = 'layout'; case VIEW_SETTINGS = 'viewSettings'; } diff --git a/lib/Db/View.php b/lib/Db/View.php index ce419fed77..c144c92bc6 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -36,6 +36,8 @@ * @method setTableId(?int $tableId) * @method getType(): string * @method setType(string $type) + * @method getGrid(): ?string + * @method setGrid(?string $grid) * @method getColumns(): string * @method setColumns(string $columns) * @method getColumnSettings(): string @@ -101,6 +103,7 @@ class View extends EntitySuper implements JsonSerializable { protected ?string $sort = null; // json protected ?string $filter = null; // json protected ?string $type = null; + protected ?string $grid = null; // json protected ?string $layout = null; protected ?string $viewSettings = null; // json @@ -231,6 +234,21 @@ public function setFilterArray(array $array):void { $this->setFilter(\json_encode($array)); } + /** + * The widgets of a grid view and where they sit, as GridWidgetTypes stored them. + * + * @return array{widgets: list>, layout: list>} + */ + public function getGridArray(): array { + $grid = $this->getArray($this->getGrid()); + $list = static fn (mixed $items): array => array_values(array_filter(is_array($items) ? $items : [], is_array(...))); + return ['widgets' => $list($grid['widgets'] ?? null), 'layout' => $list($grid['layout'] ?? null)]; + } + + public function setGridArray(array $array): void { + $this->setGrid(\json_encode($array, JSON_THROW_ON_ERROR)); + } + /** * @return 'table'|'grid' */ @@ -297,6 +315,7 @@ public function jsonSerialize(): array { 'isFederated' => $this->isFederated(), 'sidebarOrder' => $this->sidebarOrder, 'type' => $this->getTypeOrDefault(), + 'grid' => $this->getGridArray(), 'layout' => $this->getLayoutNormalized(), 'viewSettings' => $this->getViewSettingsObject()->jsonSerialize(), ]; diff --git a/lib/Migration/Version2400Date20261012010000.php b/lib/Migration/Version2400Date20261012010000.php new file mode 100644 index 0000000000..6d5917f367 --- /dev/null +++ b/lib/Migration/Version2400Date20261012010000.php @@ -0,0 +1,40 @@ +hasTable('tables_views')) { + $table = $schema->getTable('tables_views'); + if (!$table->hasColumn('grid')) { + $table->addColumn('grid', Types::TEXT, [ + 'notnull' => false, + 'default' => null, + ]); + } + } + + return $schema; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index d73d2ee024..0dec727221 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -35,6 +35,7 @@ public function __construct( protected readonly ?SortRuleSet $sortRuleSet = null, protected readonly ?int $sidebarOrder = null, protected readonly ?string $type = null, + protected readonly ?array $grid = null, protected readonly ?string $layout = null, protected readonly ?ViewSettings $viewSettings = null, ) { @@ -68,6 +69,9 @@ public function updateDetail(): Generator { if ($this->type !== null) { yield ViewUpdatableParameters::TYPE => $this->type; } + if ($this->grid !== null) { + yield ViewUpdatableParameters::GRID => $this->grid; + } if ($this->layout !== null) { yield ViewUpdatableParameters::LAYOUT => $this->layout; } @@ -89,12 +93,13 @@ public function updateDetail(): Generator { * viewSettings?: array{cardBackgroundSource?: int|null, cardTitleSource?: int|null}|string, * filter?: list}>>, * sidebarOrder?: int, - * type?: 'table'|'grid' + * type?: 'table'|'grid', + * grid?: array{widgets?: list>, layout?: list>} * } $data * @param array $columnsMap */ public static function fromInputArray(array $data, array $columnsMap = []): self { - $data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort', 'viewSettings']); + $data = self::transformJsonToArrayInPayload($data, ['columnSettings', 'filter', 'sort', 'grid', 'viewSettings']); if (isset($data['columns']) && !isset($data['columnSettings'])) { $logger = Server::get(LoggerInterface::class); @@ -122,6 +127,7 @@ public static function fromInputArray(array $data, array $columnsMap = []): self sortRuleSet: isset($data['sort']) ? SortRuleSet::createFromInputArray($data['sort'], $columnsMap) : null, sidebarOrder: (array_key_exists('sidebarOrder', $data) && $data['sidebarOrder'] !== null) ? (int)$data['sidebarOrder'] : null, type: self::optionalType($data), + grid: self::optionalArray($data, 'grid'), layout: $layout, viewSettings: $viewSettings, ); @@ -151,6 +157,19 @@ private static function optionalType(array $data): ?string { return $type; } + /** + * @throws InvalidArgumentException + */ + private static function optionalArray(array $data, string $key): ?array { + if (!isset($data[$key])) { + return null; + } + if (!is_array($data[$key])) { + throw new InvalidArgumentException('The view property "' . $key . '" must be an object.'); + } + return $data[$key]; + } + private static function createViewSettingsFromInputData(array $data, array $columnsMap = []): ?ViewSettings { if (array_key_exists('viewSettings', $data)) { if ($data['viewSettings'] === null) { diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cef1886389..379cd36a8f 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -13,6 +13,25 @@ namespace OCA\Tables; /** + * @psalm-type TablesGridWidgetValue = string|bool|int|array{type: 'table'|'view', id: int}|null + * + * @psalm-type TablesGridWidget = array{ + * id: string, + * type: string, + * configuration: array, + * content: array, + * broken?: bool, + * } + * + * @psalm-type TablesGridLayoutItem = array{ + * id: string, + * widgetId: string, + * gridX: int, + * gridY: int, + * gridWidth: int, + * gridHeight: int, + * } + * * @psalm-type TablesView = array{ * id: int, * uuid: string, @@ -48,6 +67,7 @@ * isFederated: bool, * sidebarOrder: int|null, * type: 'table'|'grid', + * grid: array{widgets: list, layout: list}, * } * * @psalm-type TablesTable = array{ diff --git a/lib/Service/GridWidgetTypes.php b/lib/Service/GridWidgetTypes.php new file mode 100644 index 0000000000..36d8333946 --- /dev/null +++ b/lib/Service/GridWidgetTypes.php @@ -0,0 +1,374 @@ +, required?: bool} + * @psalm-type TablesGridWidgetType = array{type: string, title: string, defaultWidth: int, defaultHeight: int, configuration: array, properties: array} + */ +class GridWidgetTypes { + public const TYPE_HEADER = 'header'; + public const TYPE_TEXT = 'text'; + public const TYPE_DATA = 'data'; + + public const PROPERTY_TYPES = ['string', 'text', 'color', 'boolean', 'integer', 'enum', 'target']; + + private const HEX_COLOR = '/^#[0-9a-fA-F]{3,6}$/'; + private const LAYOUT_POSITIONS = ['gridX', 'gridY']; + private const LAYOUT_SIZES = ['gridWidth', 'gridHeight']; + + public function __construct( + private readonly IL10N $l10n, + private readonly ViewMapper $viewMapper, + private readonly TableMapper $tableMapper, + private readonly PermissionsService $permissionsService, + ) { + } + + /** + * @return array + */ + public function all(): array { + return [ + self::TYPE_HEADER => [ + 'type' => self::TYPE_HEADER, + 'title' => $this->l10n->t('Header'), + 'defaultWidth' => 12, + 'defaultHeight' => 2, + 'configuration' => $this->titleConfiguration(false), + 'properties' => [ + 'title' => ['type' => 'string', 'title' => $this->l10n->t('Heading'), 'default' => '', 'required' => true], + 'subtitle' => ['type' => 'string', 'title' => $this->l10n->t('Subheading'), 'default' => ''], + 'textAlign' => ['type' => 'enum', 'title' => $this->l10n->t('Text alignment'), 'default' => 'left', 'options' => [ + ['value' => 'left', 'label' => $this->l10n->t('Start')], + ['value' => 'center', 'label' => $this->l10n->t('Centered')], + ['value' => 'right', 'label' => $this->l10n->t('End')], + ]], + 'backgroundColor' => ['type' => 'color', 'title' => $this->l10n->t('Background color'), 'default' => ''], + 'textColor' => ['type' => 'color', 'title' => $this->l10n->t('Text color'), 'default' => ''], + ], + ], + self::TYPE_TEXT => [ + 'type' => self::TYPE_TEXT, + 'title' => $this->l10n->t('Description'), + 'defaultWidth' => 6, + 'defaultHeight' => 2, + 'configuration' => $this->titleConfiguration(true), + 'properties' => [ + 'text' => ['type' => 'text', 'title' => $this->l10n->t('Text'), 'default' => ''], + ], + ], + self::TYPE_DATA => [ + 'type' => self::TYPE_DATA, + 'title' => $this->l10n->t('Table or view'), + 'defaultWidth' => 12, + 'defaultHeight' => 5, + 'configuration' => $this->titleConfiguration(false), + 'properties' => [ + 'target' => ['type' => 'target', 'title' => $this->l10n->t('Table or view'), 'default' => null, 'required' => true], + ], + ], + ]; + } + + /** + * @return array + */ + private function titleConfiguration(bool $showTitle): array { + return [ + 'title' => ['type' => 'string', 'title' => $this->l10n->t('Title'), 'default' => ''], + 'showTitle' => ['type' => 'boolean', 'title' => $this->l10n->t('Show the title'), 'default' => $showTitle], + ]; + } + + /** + * @return TablesGridWidgetType + * @throws BadRequestError + */ + public function get(string $type): array { + $types = $this->all(); + if (!isset($types[$type])) { + throw new BadRequestError('Unknown widget type "' . $type . '", expected one of: ' . implode(', ', array_keys($types))); + } + return $types[$type]; + } + + /** + * Validates a grid for the user who stores it and returns it with every widget reduced + * to what its schemas allow. The grid stored so far tells which targets are unchanged. + * + * @param array $storedGrid + * @return array{widgets: list, layout: list} + * @throws BadRequestError + * @throws Exception + */ + public function sanitizeGrid(mixed $grid, string $userId, array $storedGrid = []): array { + if (!is_array($grid)) { + throw new BadRequestError('The grid must be an object.'); + } + foreach (['widgets', 'layout'] as $key) { + if (isset($grid[$key]) && (!is_array($grid[$key]) || !array_is_list($grid[$key]))) { + throw new BadRequestError('The grid property "' . $key . '" must be a list.'); + } + } + + $widgets = []; + foreach ($grid['widgets'] ?? [] as $widget) { + if (!is_array($widget)) { + throw new BadRequestError('Every widget needs a string id and type.'); + } + $sanitized = $this->sanitizeWidget($widget); + if (isset($widgets[$sanitized['id']])) { + throw new BadRequestError('Two widgets share the id ' . $sanitized['id'] . '.'); + } + $widgets[$sanitized['id']] = $sanitized; + } + $widgets = $this->checkTargets($widgets, $userId, $this->storedTargets($storedGrid)); + + $layout = []; + foreach ($grid['layout'] ?? [] as $item) { + $sanitized = $this->sanitizeLayoutItem($item, $widgets); + if (isset($layout[$sanitized['widgetId']])) { + throw new BadRequestError('The layout places widget ' . $sanitized['widgetId'] . ' twice.'); + } + $layout[$sanitized['widgetId']] = $sanitized; + } + + return ['widgets' => array_values($widgets), 'layout' => array_values($layout)]; + } + + /** + * Returns a widget in the stored shape. + * + * @param array $widget + * @return TablesGridWidget + * @throws BadRequestError + */ + public function sanitizeWidget(array $widget): array { + if (!is_string($widget['id'] ?? null) || $widget['id'] === '' || !is_string($widget['type'] ?? null)) { + throw new BadRequestError('Every widget needs a string id and type.'); + } + $configuration = $widget['configuration'] ?? []; + if (!is_array($configuration)) { + throw new BadRequestError('The configuration of widget ' . $widget['id'] . ' must be an object.'); + } + $content = $widget['content'] ?? []; + if (!is_array($content)) { + throw new BadRequestError('The content of widget ' . $widget['id'] . ' must be an object.'); + } + $type = $this->get($widget['type']); + return [ + 'id' => $widget['id'], + 'type' => $widget['type'], + 'configuration' => $this->sanitizeProperties($widget['type'], $type['configuration'], $configuration), + 'content' => $this->sanitizeProperties($widget['type'], $type['properties'], $content), + ]; + } + + /** + * @param array $widgets + * @return TablesGridLayoutItem + * @throws BadRequestError + */ + private function sanitizeLayoutItem(mixed $item, array $widgets): array { + if (!is_array($item) || !is_string($item['widgetId'] ?? null)) { + throw new BadRequestError('Every layout item needs the id of its widget.'); + } + if (!isset($widgets[$item['widgetId']])) { + throw new BadRequestError('The layout places widget ' . $item['widgetId'] . ', which the grid does not have.'); + } + foreach (self::LAYOUT_POSITIONS as $position) { + if (!is_int($item[$position] ?? null) || $item[$position] < 0) { + throw new BadRequestError('The layout position "' . $position . '" must be a non-negative integer.'); + } + } + foreach (self::LAYOUT_SIZES as $size) { + if (!is_int($item[$size] ?? null) || $item[$size] < 1) { + throw new BadRequestError('The layout size "' . $size . '" must be a positive integer.'); + } + } + return [ + 'id' => $item['widgetId'], + 'widgetId' => $item['widgetId'], + 'gridX' => $item['gridX'], + 'gridY' => $item['gridY'], + 'gridWidth' => $item['gridWidth'], + 'gridHeight' => $item['gridHeight'], + ]; + } + + /** + * A data widget shows rows, so a new or changed target must exist, hold rows and be readable + * for the user; every unusable one gets the same error, so it reveals nothing about others' + * views. A target that was stored before is kept as it is, so a save does not depend on the + * saver's access to widgets they did not touch, and is marked broken once it no longer exists. + * + * @param array $widgets + * @param array $storedTargets + * @return array + * @throws BadRequestError + * @throws Exception + */ + private function checkTargets(array $widgets, string $userId, array $storedTargets): array { + $targetIds = ['table' => [], 'view' => []]; + foreach ($widgets as $widget) { + $target = $widget['content']['target'] ?? null; + if (is_array($target)) { + $targetIds[$target['type']][] = $target['id']; + } + } + $elements = [ + 'table' => $targetIds['table'] === [] ? [] : $this->tableMapper->findMany($targetIds['table']), + 'view' => $targetIds['view'] === [] ? [] : $this->viewMapper->findMany($targetIds['view']), + ]; + foreach ($widgets as $widgetId => $widget) { + $target = $widget['content']['target'] ?? null; + if (!is_array($target)) { + continue; + } + $element = $elements[$target['type']][$target['id']] ?? null; + $exists = $element !== null && !($element instanceof View && $element->isStandalone()); + if (($storedTargets[$widgetId] ?? null) === $target) { + // an unchanged target is kept; one that no longer exists is marked until the widget is saved valid again + if (!$exists) { + $widgets[$widgetId]['broken'] = true; + } + continue; + } + if (!$exists || !$this->permissionsService->canReadRowsByElement($element, $target['type'], $userId)) { + throw new BadRequestError('A table or view widget can only show a table or view whose rows you can read.'); + } + } + return $widgets; + } + + /** + * The target of every widget in the stored grid, by widget id. + * + * @return array + */ + private function storedTargets(array $storedGrid): array { + $targets = []; + foreach (is_array($storedGrid['widgets'] ?? null) ? $storedGrid['widgets'] : [] as $widget) { + $target = is_array($widget) ? ($widget['content']['target'] ?? null) : null; + if (is_string($widget['id'] ?? null) && is_array($target) && in_array($target['type'] ?? null, ['table', 'view'], true) && is_int($target['id'] ?? null)) { + $targets[$widget['id']] = ['type' => $target['type'], 'id' => $target['id']]; + } + } + return $targets; + } + + /** + * Every property checked against its schema, defaults filled in and unknown properties dropped. + * + * @param array $schema + * @param array $values + * @return array + * @throws BadRequestError + */ + private function sanitizeProperties(string $type, array $schema, array $values): array { + $sanitized = []; + foreach ($schema as $name => $property) { + $value = array_key_exists($name, $values) ? $values[$name] : $property['default']; + $sanitized[$name] = $this->sanitizeProperty($type . '.' . $name, $property, $value); + } + return $sanitized; + } + + /** + * @param TablesGridWidgetProperty $property + * @return TablesGridWidgetValue + * @throws BadRequestError + */ + private function sanitizeProperty(string $label, array $property, mixed $value): string|bool|int|array|null { + $isRequired = $property['required'] ?? false; + return match ($property['type']) { + 'string', 'text' => $this->sanitizeText($label, $value, $isRequired), + 'color' => $this->sanitizeColor($label, $value), + 'boolean' => is_bool($value) ? $value : throw new BadRequestError('Widget property ' . $label . ' must be true or false.'), + 'integer' => is_int($value) ? $value : throw new BadRequestError('Widget property ' . $label . ' must be an integer.'), + 'enum' => $this->sanitizeEnum($label, $value, array_column($property['options'] ?? [], 'value')), + 'target' => $this->sanitizeTarget($label, $value, $isRequired), + default => throw new BadRequestError('Widget property ' . $label . ' has an unknown schema type.'), + }; + } + + /** + * @throws BadRequestError + */ + private function sanitizeText(string $label, mixed $value, bool $isRequired): string { + $value ??= ''; + if (!is_string($value)) { + throw new BadRequestError('Widget property ' . $label . ' must be a string.'); + } + if ($isRequired && trim($value) === '') { + throw new BadRequestError('Widget property ' . $label . ' is required.'); + } + return $value; + } + + /** + * @throws BadRequestError + */ + private function sanitizeColor(string $label, mixed $value): string { + if ($value === null || $value === '') { + return ''; + } + if (!is_string($value) || !preg_match(self::HEX_COLOR, $value)) { + throw new BadRequestError('Widget property ' . $label . ' must be a hex color.'); + } + return $value; + } + + /** + * @param list $allowed + * @throws BadRequestError + */ + private function sanitizeEnum(string $label, mixed $value, array $allowed): string { + if (!is_string($value) || !in_array($value, $allowed, true)) { + throw new BadRequestError('Widget property ' . $label . ' must be one of: ' . implode(', ', $allowed) . '.'); + } + return $value; + } + + /** + * @return array{type: 'table'|'view', id: int}|null + * @throws BadRequestError + */ + private function sanitizeTarget(string $label, mixed $value, bool $isRequired): ?array { + if ($value === null) { + if ($isRequired) { + throw new BadRequestError('Widget property ' . $label . ' is required.'); + } + return null; + } + $type = is_array($value) ? ($value['type'] ?? null) : null; + $id = is_array($value) ? ($value['id'] ?? null) : null; + if (($type !== 'table' && $type !== 'view') || !is_int($id) || $id <= 0) { + throw new BadRequestError('Widget property ' . $label . ' must name a table or view by id.'); + } + return ['type' => $type, 'id' => $id]; + } +} diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index fbceb8bf3b..e2e9ef703a 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -12,6 +12,7 @@ use DateTime; use Exception; use InvalidArgumentException; +use JsonException; use JsonSerializable; use OCA\Tables\Activity\ActivityManager; use OCA\Tables\Activity\ChangeSet; @@ -65,6 +66,7 @@ public function __construct( protected IL10N $l, private readonly FederationService $federationService, private readonly ActivityManager $activityManager, + private readonly GridWidgetTypes $gridWidgetTypes, ) { parent::__construct($logger, $userId, $permissionsService); } @@ -325,9 +327,22 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } } - $insertableValue = $value instanceof JsonSerializable - ? json_encode($value) - : $value; + if ($parameter === ViewUpdatableParameters::GRID) { + // the type is applied before the grid, so a request can turn a view into a grid view and fill it + if ($view->getTypeOrDefault() !== View::TYPE_GRID) { + throw new BadRequestError('Only a grid view holds a grid.'); + } + $value = $this->gridWidgetTypes->sanitizeGrid($value, $userId, $view->getGridArray()); + } + + $insertableValue = $value; + if ($value instanceof JsonSerializable || is_array($value)) { + try { + $insertableValue = json_encode($value, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new BadRequestError('The view property "' . $parameter->value . '" cannot be stored: ' . $e->getMessage()); + } + } $setterMethod = 'set' . ucfirst((string)$parameter->value); $view->$setterMethod($insertableValue); @@ -854,6 +869,10 @@ public function importView(?int $tableId, array $view, string $userId): View { $type = $view['type'] ?? View::TYPE_TABLE; $this->assertTypeValid($type); $item->setType($type); + // every exported view carries a grid, so only a grid view takes it over + if ($type === View::TYPE_GRID && isset($view['grid'])) { + $item->setGridArray($this->gridWidgetTypes->sanitizeGrid($view['grid'], $userId)); + } // A scheme may carry a layout this version does not know, which is dropped rather // than rejected so the rest of the view still imports. // Resolved the same way the write path resolves it, so import and create agree: an diff --git a/openapi.json b/openapi.json index a9185f3ed7..3c2acd4677 100644 --- a/openapi.json +++ b/openapi.json @@ -330,6 +330,108 @@ } } }, + "GridLayoutItem": { + "type": "object", + "required": [ + "id", + "widgetId", + "gridX", + "gridY", + "gridWidth", + "gridHeight" + ], + "properties": { + "id": { + "type": "string" + }, + "widgetId": { + "type": "string" + }, + "gridX": { + "type": "integer", + "format": "int64" + }, + "gridY": { + "type": "integer", + "format": "int64" + }, + "gridWidth": { + "type": "integer", + "format": "int64" + }, + "gridHeight": { + "type": "integer", + "format": "int64" + } + } + }, + "GridWidget": { + "type": "object", + "required": [ + "id", + "type", + "configuration", + "content" + ], + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "configuration": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetValue" + } + }, + "content": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/GridWidgetValue" + } + }, + "broken": { + "type": "boolean" + } + } + }, + "GridWidgetValue": { + "nullable": true, + "oneOf": [ + { + "type": "string" + }, + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "table", + "view" + ] + }, + "id": { + "type": "integer", + "format": "int64" + } + } + } + ] + }, "ImportState": { "type": "object", "required": [ @@ -1017,7 +1119,8 @@ "rowsCount", "isFederated", "sidebarOrder", - "type" + "type", + "grid" ], "properties": { "id": { @@ -1295,6 +1398,27 @@ "table", "grid" ] + }, + "grid": { + "type": "object", + "required": [ + "widgets", + "layout" + ], + "properties": { + "widgets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GridWidget" + } + }, + "layout": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GridLayoutItem" + } + } + } } } } diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 867ae05227..4a228ad7d6 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1162,6 +1162,35 @@ export type components = { readonly displayMode: number; readonly userId: string; }; + readonly GridLayoutItem: { + readonly id: string; + readonly widgetId: string; + /** Format: int64 */ + readonly gridX: number; + /** Format: int64 */ + readonly gridY: number; + /** Format: int64 */ + readonly gridWidth: number; + /** Format: int64 */ + readonly gridHeight: number; + }; + readonly GridWidget: { + readonly id: string; + readonly type: string; + readonly configuration: { + readonly [key: string]: components["schemas"]["GridWidgetValue"]; + }; + readonly content: { + readonly [key: string]: components["schemas"]["GridWidgetValue"]; + }; + readonly broken?: boolean; + }; + readonly GridWidgetValue: (string | boolean | number | { + /** @enum {string} */ + readonly type: "table" | "view"; + /** Format: int64 */ + readonly id: number; + }) | null; readonly ImportState: { /** Format: int64 */ readonly found_columns_count: number; @@ -1426,6 +1455,10 @@ export type components = { readonly sidebarOrder: number | null; /** @enum {string} */ readonly type: "table" | "grid"; + readonly grid: { + readonly widgets: readonly components["schemas"]["GridWidget"][]; + readonly layout: readonly components["schemas"]["GridLayoutItem"][]; + }; }; }; responses: never; diff --git a/tests/unit/Db/ViewTest.php b/tests/unit/Db/ViewTest.php index ab1403dd3e..22507e7630 100644 --- a/tests/unit/Db/ViewTest.php +++ b/tests/unit/Db/ViewTest.php @@ -13,6 +13,32 @@ use PHPUnit\Framework\TestCase; class ViewTest extends TestCase { + public function testGridArrayIsEmptyWhenNothingIsStored(): void { + $view = new View(); + $this->assertSame(['widgets' => [], 'layout' => []], $view->getGridArray()); + + $view->setGrid('null'); + $this->assertSame(['widgets' => [], 'layout' => []], $view->getGridArray()); + } + + public function testGridArrayDropsUnknownKeysAndNonLists(): void { + $view = new View(); + $view->setGrid(json_encode(['widgets' => 'oops', 'layout' => [['widgetId' => 'w-1']], 'extra' => true])); + + $this->assertSame(['widgets' => [], 'layout' => [['widgetId' => 'w-1']]], $view->getGridArray()); + } + + public function testGridArrayRoundTrips(): void { + $grid = [ + 'widgets' => [['id' => 'w-1', 'type' => 'header', 'configuration' => ['title' => 'Hi', 'showTitle' => false], 'content' => ['title' => 'Hi']]], + 'layout' => [['id' => 'w-1', 'widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2]], + ]; + $view = new View(); + $view->setGridArray($grid); + + $this->assertSame($grid, $view->getGridArray()); + } + public function testTypeFallsBackToTable(): void { $view = new View(); $this->assertSame(View::TYPE_TABLE, $view->getTypeOrDefault()); @@ -61,16 +87,18 @@ public function testOnlyAViewWithoutTableAndExternalIdIsStandalone(): void { $this->assertFalse($view->isStandalone()); } - public function testJsonSerializeCarriesTypeAndTechnicalName(): void { + public function testJsonSerializeCarriesTypeGridAndTechnicalName(): void { $view = new View(); $view->setType(View::TYPE_GRID); $view->setTechnicalName('home'); $view->setCreatedBy('alice'); + $view->setGridArray(['widgets' => [], 'layout' => []]); $json = $view->jsonSerialize(); $this->assertSame('grid', $json['type']); $this->assertSame('home', $json['technicalName']); + $this->assertSame(['widgets' => [], 'layout' => []], $json['grid']); $this->assertSame(-1, $json['tableId']); $this->assertSame('alice', $json['ownership']); } diff --git a/tests/unit/Model/ViewUpdateInputTest.php b/tests/unit/Model/ViewUpdateInputTest.php index a48d7de59e..a8ac81cea8 100644 --- a/tests/unit/Model/ViewUpdateInputTest.php +++ b/tests/unit/Model/ViewUpdateInputTest.php @@ -40,8 +40,9 @@ public function testUpdateDetailIncludesEmptyDescription(): void { $this->assertSame('', $updates[ViewUpdatableParameters::DESCRIPTION->value]); } - public function testUpdateDetailIncludesTypeAndTechnicalName(): void { - $input = ViewUpdateInput::fromInputArray(['type' => 'grid', 'technicalName' => 'home']); + public function testUpdateDetailIncludesTypeGridAndTechnicalName(): void { + $grid = ['widgets' => [['id' => 'w-1', 'type' => 'header']], 'layout' => [['id' => 1, 'widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2]]]; + $input = ViewUpdateInput::fromInputArray(['type' => 'grid', 'grid' => json_encode($grid), 'technicalName' => 'home']); $updates = []; foreach ($input->updateDetail() as $parameter => $value) { @@ -49,10 +50,11 @@ public function testUpdateDetailIncludesTypeAndTechnicalName(): void { } $this->assertSame('grid', $updates[ViewUpdatableParameters::TYPE->value]); + $this->assertSame($grid, $updates[ViewUpdatableParameters::GRID->value]); $this->assertSame('home', $updates[ViewUpdatableParameters::TECHNICAL_NAME->value]); } - public function testUpdateDetailSkipsTypeWhenAbsent(): void { + public function testUpdateDetailSkipsGridWhenAbsent(): void { $input = ViewUpdateInput::fromInputArray(['title' => 'Only a title']); $updates = []; @@ -60,6 +62,7 @@ public function testUpdateDetailSkipsTypeWhenAbsent(): void { $updates[$parameter->value] = $value; } + $this->assertArrayNotHasKey(ViewUpdatableParameters::GRID->value, $updates); $this->assertArrayNotHasKey(ViewUpdatableParameters::TYPE->value, $updates); $this->assertArrayNotHasKey(ViewUpdatableParameters::TECHNICAL_NAME->value, $updates); } @@ -76,6 +79,12 @@ public function testAnUnknownTypeIsRejected(): void { ViewUpdateInput::fromInputArray(['type' => 'kanban']); } + public function testAGridThatIsNotAnObjectIsRejected(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('"grid" must be an object'); + ViewUpdateInput::fromInputArray(['grid' => 'not json']); + } + public function testUpdateDetailCarriesAKnownLayout(): void { $input = ViewUpdateInput::fromInputArray(['layout' => 'gallery']); diff --git a/tests/unit/Service/GridWidgetTypesTest.php b/tests/unit/Service/GridWidgetTypesTest.php new file mode 100644 index 0000000000..a587423786 --- /dev/null +++ b/tests/unit/Service/GridWidgetTypesTest.php @@ -0,0 +1,231 @@ +createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => $text); + $this->viewMapper = $this->createMock(ViewMapper::class); + $this->tableMapper = $this->createMock(TableMapper::class); + $this->permissionsService = $this->createMock(PermissionsService::class); + $this->types = new GridWidgetTypes($l10n, $this->viewMapper, $this->tableMapper, $this->permissionsService); + } + + public function testEveryTypeDescribesItselfAndItsProperties(): void { + foreach ($this->types->all() as $type => $definition) { + $this->assertSame($type, $definition['type']); + $this->assertNotSame('', $definition['title']); + $this->assertGreaterThan(0, $definition['defaultWidth']); + $this->assertGreaterThan(0, $definition['defaultHeight']); + foreach ($definition['properties'] as $name => $property) { + $this->assertContains($property['type'], GridWidgetTypes::PROPERTY_TYPES, $type . '.' . $name); + $this->assertArrayHasKey('default', $property, $type . '.' . $name); + if ($property['type'] === 'enum') { + $this->assertContains($property['default'], array_column($property['options'], 'value'), $type . '.' . $name); + } + } + } + } + + public function testTitlesAreTranslated(): void { + $l10n = $this->createMock(IL10N::class); + $l10n->method('t')->willReturnCallback(static fn (string $text): string => 'nl:' . $text); + $types = (new GridWidgetTypes($l10n, $this->viewMapper, $this->tableMapper, $this->permissionsService))->all(); + + $this->assertSame('nl:Header', $types['header']['title']); + $this->assertSame('nl:Heading', $types['header']['properties']['title']['title']); + $this->assertSame('nl:Centered', $types['header']['properties']['textAlign']['options'][1]['label']); + } + + public function testEveryTypeHasATitleAndShowTitleConfiguration(): void { + foreach ($this->types->all() as $definition) { + $this->assertSame('string', $definition['configuration']['title']['type']); + $this->assertSame('boolean', $definition['configuration']['showTitle']['type']); + } + } + + public function testSanitizeWidgetSplitsConfigurationFromContent(): void { + $widget = $this->types->sanitizeWidget(['id' => 'w-1', 'type' => 'text', 'configuration' => ['title' => 'How', 'showTitle' => true, 'stray' => 'x'], 'content' => ['text' => 'it works']]); + + $this->assertSame(['id' => 'w-1', 'type' => 'text', 'configuration' => ['title' => 'How', 'showTitle' => true], 'content' => ['text' => 'it works']], $widget); + } + + public function testSanitizeWidgetFillsTheConfigurationDefaultsOfTheType(): void { + $this->assertFalse($this->types->sanitizeWidget(['id' => 'w-1', 'type' => 'header', 'content' => ['title' => 'Hi']])['configuration']['showTitle']); + $this->assertTrue($this->types->sanitizeWidget(['id' => 'w-2', 'type' => 'text'])['configuration']['showTitle']); + } + + public function testSanitizeFillsDefaultsAndDropsUnknownProperties(): void { + $widget = $this->types->sanitizeWidget(['id' => 'w-1', 'type' => 'header', 'content' => ['title' => 'Hi', 'stray' => true]]); + + $this->assertSame(['title' => 'Hi', 'subtitle' => '', 'textAlign' => 'left', 'backgroundColor' => '', 'textColor' => ''], $widget['content']); + } + + public function testSanitizeKeepsAValidTarget(): void { + $widget = $this->types->sanitizeWidget(['id' => 'w-1', 'type' => 'data', 'content' => ['target' => ['type' => 'view', 'id' => 6, 'extra' => 1]]]); + + $this->assertSame(['target' => ['type' => 'view', 'id' => 6]], $widget['content']); + } + + public function testSanitizeGridIdentifiesLayoutItemsByTheirWidget(): void { + $grid = $this->types->sanitizeGrid([ + 'widgets' => [['id' => 'w-1', 'type' => 'text']], + 'layout' => [['id' => 7, 'widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2, 'stray' => true]], + ], 'alice'); + + $this->assertSame([['id' => 'w-1', 'widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]], $grid['layout']); + } + + public static function invalidGrids(): array { + $text = ['id' => 'w-1', 'type' => 'text']; + $place = static fn (array $item): array => $item + ['widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 6, 'gridHeight' => 2]; + $widget = static fn (array $widget): array => ['widgets' => [$widget + ['id' => 'w-1']]]; + return [ + 'widget without id' => [['widgets' => [['type' => 'text']]], 'string id and type'], + 'unknown widget type' => [$widget(['type' => 'kanban']), 'Unknown widget type'], + 'missing required string' => [$widget(['type' => 'header', 'content' => ['title' => ' ']]), 'header.title is required'], + 'non-hex color' => [$widget(['type' => 'header', 'content' => ['title' => 'Hi', 'backgroundColor' => 'red']]), 'must be a hex color'], + 'unknown enum value' => [$widget(['type' => 'header', 'content' => ['title' => 'Hi', 'textAlign' => 'justify']]), 'textAlign must be one of'], + 'non-boolean is not cast' => [$widget(['type' => 'text', 'configuration' => ['showTitle' => 1]]), 'showTitle must be true or false'], + 'target without id' => [$widget(['type' => 'data', 'content' => ['target' => ['type' => 'table']]]), 'name a table or view by id'], + 'missing required target' => [$widget(['type' => 'data']), 'data.target is required'], + 'non-string text' => [$widget(['type' => 'text', 'content' => ['text' => ['nested']]]), 'text.text must be a string'], + 'widgets not a list' => [['widgets' => ['a' => $text]], '"widgets" must be a list'], + 'two widgets with one id' => [['widgets' => [$text, $text]], 'share the id'], + 'layout item without its widget' => [['widgets' => [], 'layout' => [$place(['widgetId' => 'w-gone'])]], 'which the grid does not have'], + 'negative position' => [['widgets' => [$text], 'layout' => [$place(['gridX' => -1])]], 'gridX'], + 'widget placed twice' => [['widgets' => [$text], 'layout' => [$place([]), $place([])]], 'places widget w-1 twice'], + 'layout item without size' => [['widgets' => [$text], 'layout' => [$place(['gridWidth' => 0])]], '"gridWidth" must be a positive integer'], + ]; + } + + /** + * @dataProvider invalidGrids + */ + public function testSanitizeGridRejectsInvalidInput(array $grid, string $message): void { + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage($message); + $this->types->sanitizeGrid($grid, 'alice'); + } + + private function dataWidget(string $type, int $id): array { + return ['id' => 'w-1', 'type' => 'data', 'content' => ['target' => ['type' => $type, 'id' => $id]]]; + } + + public function testSanitizeGridKeepsATargetTheUserCanRead(): void { + $table = new Table(); + $table->setId(4); + $this->tableMapper->method('findMany')->with([4])->willReturn([4 => $table]); + $this->permissionsService->method('canReadRowsByElement')->with($table, 'table', 'alice')->willReturn(true); + + $grid = $this->types->sanitizeGrid(['widgets' => [$this->dataWidget('table', 4)]], 'alice'); + + $this->assertSame(['type' => 'table', 'id' => 4], $grid['widgets'][0]['content']['target']); + } + + public function testSanitizeGridRefusesTargetsTheUserCannotUseWithOneMessage(): void { + $gridView = new View(); + $gridView->setId(9); + $readableTable = new Table(); + $readableTable->setId(4); + $this->viewMapper->method('findMany')->willReturn([9 => $gridView]); + $this->tableMapper->method('findMany')->willReturn([4 => $readableTable]); + // the owner can read their own grid view, so only the "has a table" check may refuse it + $this->permissionsService->method('canReadRowsByElement')->willReturnCallback(static fn ($element): bool => $element === $readableTable || $element === $gridView); + + $cases = [ + 'a view without a table' => $this->dataWidget('view', 9), + 'a table that does not exist' => $this->dataWidget('table', 5), + 'a view that does not exist' => $this->dataWidget('view', 10), + ]; + foreach ($cases as $case => $widget) { + try { + $this->types->sanitizeGrid(['widgets' => [$widget]], 'alice'); + $this->fail($case . ' was accepted'); + } catch (BadRequestError $e) { + $this->assertSame('A table or view widget can only show a table or view whose rows you can read.', $e->getMessage(), $case); + } + } + } + + public function testSanitizeGridRefusesATableTheUserCannotRead(): void { + $table = new Table(); + $table->setId(4); + $this->tableMapper->method('findMany')->willReturn([4 => $table]); + $this->permissionsService->method('canReadRowsByElement')->willReturn(false); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('whose rows you can read'); + $this->types->sanitizeGrid(['widgets' => [$this->dataWidget('table', 4)]], 'alice'); + } + + public function testSanitizeGridKeepsAnUnchangedTargetTheSaverCannotRead(): void { + $table = new Table(); + $table->setId(4); + $this->tableMapper->method('findMany')->willReturn([4 => $table]); + $this->permissionsService->expects($this->never())->method('canReadRowsByElement'); + $widget = $this->dataWidget('table', 4); + + $grid = $this->types->sanitizeGrid(['widgets' => [$widget]], 'bob', ['widgets' => [$widget]]); + + $this->assertSame(['type' => 'table', 'id' => 4], $grid['widgets'][0]['content']['target']); + $this->assertArrayNotHasKey('broken', $grid['widgets'][0]); + } + + public function testSanitizeGridMarksAnUnchangedTargetThatNoLongerExistsAsBroken(): void { + $this->tableMapper->method('findMany')->willReturn([]); + $widget = $this->dataWidget('table', 4); + + $grid = $this->types->sanitizeGrid(['widgets' => [$widget]], 'alice', ['widgets' => [$widget]]); + + $this->assertTrue($grid['widgets'][0]['broken']); + $this->assertSame(['type' => 'table', 'id' => 4], $grid['widgets'][0]['content']['target'], 'the reference is kept'); + } + + public function testSanitizeGridClearsTheBrokenMarkWhenTheTargetIsValidAgain(): void { + $table = new Table(); + $table->setId(4); + $this->tableMapper->method('findMany')->willReturn([4 => $table]); + $widget = $this->dataWidget('table', 4) + ['broken' => true]; + + $grid = $this->types->sanitizeGrid(['widgets' => [$widget]], 'alice', ['widgets' => [$widget]]); + + $this->assertArrayNotHasKey('broken', $grid['widgets'][0]); + } + + public function testSanitizeGridChecksAChangedTargetAgainstTheSaver(): void { + $table = new Table(); + $table->setId(5); + $this->tableMapper->method('findMany')->willReturn([5 => $table]); + $this->permissionsService->method('canReadRowsByElement')->willReturn(false); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('whose rows you can read'); + $this->types->sanitizeGrid(['widgets' => [$this->dataWidget('table', 5)]], 'bob', ['widgets' => [$this->dataWidget('table', 4)]]); + } +} diff --git a/tests/unit/Service/ViewServiceTest.php b/tests/unit/Service/ViewServiceTest.php index a4813e3af6..f76ad8d45b 100644 --- a/tests/unit/Service/ViewServiceTest.php +++ b/tests/unit/Service/ViewServiceTest.php @@ -11,6 +11,7 @@ use OCA\Tables\Activity\ActivityManager; use OCA\Tables\Db\Table; +use OCA\Tables\Db\TableMapper; use OCA\Tables\Db\View; use OCA\Tables\Db\ViewMapper; use OCA\Tables\Errors\BadRequestError; @@ -20,6 +21,7 @@ use OCA\Tables\Service\ContextService; use OCA\Tables\Service\FavoritesService; use OCA\Tables\Service\FederationService; +use OCA\Tables\Service\GridWidgetTypes; use OCA\Tables\Service\PermissionsService; use OCA\Tables\Service\RowService; use OCA\Tables\Service\ShareService; @@ -35,6 +37,7 @@ class ViewServiceTest extends TestCase { private MockObject $permissionsService; private MockObject $mapper; private MockObject $activityManager; + private MockObject $tableMapper; protected function setUp(): void { parent::setUp(); @@ -43,6 +46,7 @@ protected function setUp(): void { $this->permissionsService->method('preCheckUserId')->willReturnCallback(static fn (?string $userId) => $userId ?? 'alice'); $this->mapper = $this->createMock(ViewMapper::class); $this->activityManager = $this->createMock(ActivityManager::class); + $this->tableMapper = $this->createMock(TableMapper::class); $l10n = $this->createMock(IL10N::class); $l10n->method('t')->willReturnCallback(static fn (string $text): string => $text); @@ -60,6 +64,7 @@ protected function setUp(): void { $l10n, $this->createMock(FederationService::class), $this->activityManager, + new GridWidgetTypes($l10n, $this->mapper, $this->tableMapper, $this->permissionsService), ); } @@ -80,6 +85,66 @@ private function expectUpdateOf(View $view): void { $this->mapper->method('update')->willReturnArgument(0); } + public function testUpdateKeepsAStoredTargetTheSaverCannotRead(): void { + $grid = ['widgets' => [['id' => 'w-1', 'type' => 'data', 'content' => ['target' => ['type' => 'table', 'id' => 4]]]], 'layout' => []]; + $view = $this->storedView(null); + $view->setGridArray($grid); + $view->resetUpdatedFields(); + $this->expectUpdateOf($view); + $table = new Table(); + $table->setId(4); + $this->tableMapper->method('findMany')->willReturn([4 => $table]); + $this->permissionsService->method('canReadRowsByElement')->willReturn(false); + + $updated = $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => json_encode($grid)]), 'bob', true); + + $this->assertSame(['type' => 'table', 'id' => 4], $updated->getGridArray()['widgets'][0]['content']['target'], 'the stored grid tells the target is unchanged'); + } + + public function testUpdateStoresAValidGridAsJson(): void { + $view = $this->storedView(null); + $this->expectUpdateOf($view); + $grid = [ + 'widgets' => [['id' => 'w-1', 'type' => 'header', 'content' => ['title' => 'Hi', 'backgroundColor' => '#abc', 'stray' => 'dropped']]], + 'layout' => [['id' => 1, 'widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2]], + ]; + + $updated = $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => json_encode($grid)]), 'alice', true); + + $stored = $updated->getGridArray(); + $this->assertSame([['id' => 'w-1', 'widgetId' => 'w-1', 'gridX' => 0, 'gridY' => 0, 'gridWidth' => 12, 'gridHeight' => 2]], $stored['layout'], 'a layout item is identified by its widget'); + $this->assertSame('Hi', $stored['widgets'][0]['content']['title']); + $this->assertSame('#abc', $stored['widgets'][0]['content']['backgroundColor']); + $this->assertSame('left', $stored['widgets'][0]['content']['textAlign'], 'defaults are filled in'); + $this->assertArrayNotHasKey('stray', $stored['widgets'][0]['content'], 'unknown properties are dropped'); + $this->assertSame(['title' => '', 'showTitle' => false], $stored['widgets'][0]['configuration'], 'the configuration is filled from the type'); + } + + public function testUpdateRefusesAGridOnATableView(): void { + $this->expectUpdateOf($this->storedView(4)); + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('Only a grid view holds a grid'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => json_encode(['widgets' => [], 'layout' => []])]), 'alice', true); + } + + public function testUpdateAcceptsTheGridTypeAndAGridInOneRequest(): void { + $this->expectUpdateOf($this->storedView(null)); + + $updated = $this->service->update(6, ViewUpdateInput::fromInputArray(['type' => 'grid', 'grid' => json_encode(['widgets' => [], 'layout' => []])]), 'alice', true); + + $this->assertSame(['widgets' => [], 'layout' => []], $updated->getGridArray()); + } + + public function testUpdateRefusesAGridThatCannotBeEncodedInsteadOfWipingIt(): void { + $this->expectUpdateOf($this->storedView(null)); + $grid = ['widgets' => [['id' => 'w-1', 'type' => 'text', 'content' => ['text' => "\xFF"]]], 'layout' => []]; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('"grid" cannot be stored'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => $grid]), 'alice', true); + } + public function testUpdateWritesEveryParameterToItsOwnColumn(): void { $this->expectUpdateOf($this->storedView(4)); @@ -92,6 +157,33 @@ public function testUpdateWritesEveryParameterToItsOwnColumn(): void { $this->assertSame([['columnId' => 3, 'mode' => 'ASC']], $updated->getSortArray()); } + public function testUpdateRejectsNonHexWidgetColors(): void { + $this->expectUpdateOf($this->storedView(null)); + $grid = ['widgets' => [['id' => 'w-1', 'type' => 'header', 'content' => ['title' => 'Hi', 'backgroundColor' => 'url(evil)']]], 'layout' => []]; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('hex color'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => json_encode($grid)]), 'alice', true); + } + + public function testUpdateRejectsLayoutItemsWithoutPositions(): void { + $this->expectUpdateOf($this->storedView(null)); + $grid = ['widgets' => [['id' => 'w-1', 'type' => 'text', 'content' => ['text' => 'x']]], 'layout' => [['widgetId' => 'w-1', 'gridX' => 0]]]; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('gridY'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => json_encode($grid)]), 'alice', true); + } + + public function testUpdateRejectsWidgetsWithoutIdOrType(): void { + $this->expectUpdateOf($this->storedView(null)); + $grid = ['widgets' => [['type' => 'text']], 'layout' => []]; + + $this->expectException(BadRequestError::class); + $this->expectExceptionMessage('string id and type'); + $this->service->update(6, ViewUpdateInput::fromInputArray(['grid' => json_encode($grid)]), 'alice', true); + } + public function testUpdateRefusesToTurnATablelessViewIntoATableView(): void { $this->expectUpdateOf($this->storedView(null)); @@ -222,6 +314,22 @@ public function testUpdateRefusesATechnicalNameOnAViewWithoutTable(): void { $this->service->update(6, ViewUpdateInput::fromInputArray(['technicalName' => 'home']), 'alice', true); } + public function testImportOfATableViewDoesNotTakeOverAGrid(): void { + $this->mapper->method('insert')->willReturnCallback(static function (View $view): View { + $view->setId(9); + return $view; + }); + $this->mapper->method('update')->willReturnArgument(0); + $grid = ['widgets' => [['id' => 'w-1', 'type' => 'text', 'content' => ['text' => 'x']]], 'layout' => []]; + + $imported = $this->service->importView(4, [ + 'title' => 'Open', 'emoji' => null, 'description' => '', 'type' => View::TYPE_TABLE, 'grid' => $grid, + 'createdAt' => '2026-10-12 10:00:00', 'lastEditAt' => '2026-10-12 10:00:00', 'columnSettings' => [], 'sort' => [], 'filter' => [], + ], 'alice'); + + $this->assertNull($imported->getGrid()); + } + public function testImportWithoutTableIgnoresTheSourceTechnicalName(): void { $this->mapper->method('insert')->willReturnCallback(static function (View $view): View { $view->setId(9);