Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
436da9c
feat(formatting): add DB migration for formatting column and junction…
AndyScherzinger Apr 26, 2026
665a13b
feat(formatting): add FormattingRuleColMapper for junction index table
AndyScherzinger Apr 26, 2026
74adcea
feat(formatting): add FormattingService with CRUD, validation and del…
AndyScherzinger Apr 26, 2026
378c83d
feat(formatting): add formatting to view serialization and psalm resp…
AndyScherzinger Apr 26, 2026
cb3227a
feat(formatting): wire column deletion, type-change and view deletion…
AndyScherzinger Apr 26, 2026
afb5f04
feat(formatting): add formatting export/import with column and select…
AndyScherzinger Apr 26, 2026
9a6647f
feat(formatting): add OCS REST controller, input value objects and 7 …
AndyScherzinger Apr 26, 2026
d805fee
chore(formatting): regenerate openapi spec and TypeScript types
AndyScherzinger Apr 26, 2026
91a0ead
feat(formatting): add Pinia store with debounced evaluation engine
AndyScherzinger Apr 26, 2026
21f487c
feat(formatting): add formatting manager modal and rule set list comp…
AndyScherzinger Apr 26, 2026
fca0f02
feat(formatting): add rule set editor and condition group builder com…
AndyScherzinger Apr 26, 2026
8100e42
feat(formatting): add format style picker with WCAG AA contrast enfor…
AndyScherzinger Apr 26, 2026
c758d8d
feat(formatting): add synthetic preview component for rule set styling
AndyScherzinger Apr 26, 2026
33feca0
feat(formatting): add column header formatting indicator popover
AndyScherzinger Apr 26, 2026
e9584c2
feat(formatting): wire formatting store and components into table view
AndyScherzinger Apr 26, 2026
f339053
test(formatting): add PHP unit tests for FormattingService and Format…
AndyScherzinger Apr 26, 2026
e633cd2
test(formatting): add Playwright e2e smoke tests for conditional form…
AndyScherzinger Apr 26, 2026
0c04aec
fix(test): correct mock types and table ID in formatting unit tests
AndyScherzinger Apr 26, 2026
441f401
fix(lint): resolve all ESLint and Vue template lint errors
AndyScherzinger Apr 26, 2026
9d0b72c
fix(psalm): resolve type errors in formatting types and controller de…
AndyScherzinger Apr 26, 2026
d4607ab
fix(formatting): rename 'format' param to 'style' to avoid Nextcloud …
AndyScherzinger Apr 26, 2026
9df6a09
fix(formatting): strip incomplete conditions before rule save
AndyScherzinger Apr 26, 2026
0d4a078
fix(formatting): deep-watch mutableGroups to propagate FilterEntry di…
AndyScherzinger Apr 27, 2026
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
9 changes: 9 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
['name' => 'api1#importInTable', 'url' => '/api/1/import/table/{tableId}', 'verb' => 'POST'],
['name' => 'api1#importInView', 'url' => '/api/1/import/views/{viewId}', 'verb' => 'POST'],

// -> formatting
['name' => 'FormattingApi#createRuleSet', 'url' => '/api/1/views/{viewId}/formatting/rulesets', 'verb' => 'POST'],
['name' => 'FormattingApi#updateRuleSet', 'url' => '/api/1/views/{viewId}/formatting/rulesets/{id}', 'verb' => 'PUT'],
['name' => 'FormattingApi#deleteRuleSet', 'url' => '/api/1/views/{viewId}/formatting/rulesets/{id}', 'verb' => 'DELETE'],
['name' => 'FormattingApi#reorder', 'url' => '/api/1/views/{viewId}/formatting/reorder', 'verb' => 'PUT'],
['name' => 'FormattingApi#createRule', 'url' => '/api/1/views/{viewId}/formatting/rulesets/{ruleSetId}/rules', 'verb' => 'POST'],
['name' => 'FormattingApi#updateRule', 'url' => '/api/1/views/{viewId}/formatting/rulesets/{ruleSetId}/rules/{id}', 'verb' => 'PUT'],
['name' => 'FormattingApi#deleteRule', 'url' => '/api/1/views/{viewId}/formatting/rulesets/{ruleSetId}/rules/{id}', 'verb' => 'DELETE'],

// table
['name' => 'table#index', 'url' => '/table', 'verb' => 'GET'],
['name' => 'table#show', 'url' => '/table/{id}', 'verb' => 'GET'],
Expand Down
382 changes: 382 additions & 0 deletions lib/Controller/FormattingApiController.php

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions lib/Db/FormattingRuleColMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Db;

use OCP\DB\Exception;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;

class FormattingRuleColMapper {
private string $table = 'tables_fmt_rule_cols';

public function __construct(
private readonly IDBConnection $db,
) {
}

/**
* Replace all column entries for a rule with the given set.
*
* @param int[] $columnIds
* @throws Exception
*/
public function syncForRule(string $ruleId, int $viewId, array $columnIds): void {
$this->deleteByRule($ruleId);

foreach ($columnIds as $columnId) {
$qb = $this->db->getQueryBuilder();
$qb->insert($this->table)
->values([
'rule_id' => $qb->createNamedParameter($ruleId, IQueryBuilder::PARAM_STR),
'view_id' => $qb->createNamedParameter($viewId, IQueryBuilder::PARAM_INT),
'column_id' => $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT),
])
->executeStatement();
}
}

/**
* @return list<array{rule_id: string, view_id: int}>
* @throws Exception
*/
public function findRuleIdsByColumn(int $columnId): array {
$qb = $this->db->getQueryBuilder();
$qb->select('rule_id', 'view_id')
->from($this->table)
->where($qb->expr()->eq('column_id', $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT)));

$result = $qb->executeQuery();
$rows = [];
while ($row = $result->fetch()) {
$rows[] = ['rule_id' => (string)$row['rule_id'], 'view_id' => (int)$row['view_id']];
}
$result->closeCursor();
return $rows;
}

/** @throws Exception */
public function deleteByColumn(int $columnId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->table)
->where($qb->expr()->eq('column_id', $qb->createNamedParameter($columnId, IQueryBuilder::PARAM_INT)))
->executeStatement();
}

/** @throws Exception */
public function deleteByView(int $viewId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->table)
->where($qb->expr()->eq('view_id', $qb->createNamedParameter($viewId, IQueryBuilder::PARAM_INT)))
->executeStatement();
}

/** @throws Exception */
public function deleteByRule(string $ruleId): void {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->table)
->where($qb->expr()->eq('rule_id', $qb->createNamedParameter($ruleId, IQueryBuilder::PARAM_STR)))
->executeStatement();
}
}
12 changes: 12 additions & 0 deletions lib/Db/View.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
* @method setOwnerDisplayName(string $ownerDisplayName)
* @method getOwnership(): ?string
* @method setOwnership(string $ownership)
* @method getFormatting(): ?string
* @method setFormatting(?string $formatting)
*/
class View extends EntitySuper implements JsonSerializable {
protected ?string $title = null;
Expand All @@ -74,6 +76,7 @@ class View extends EntitySuper implements JsonSerializable {
protected ?string $columns = null; // json
protected ?string $sort = null; // json
protected ?string $filter = null; // json
protected ?string $formatting = null; // json

// virtual properties
protected ?bool $isShared = null;
Expand Down Expand Up @@ -171,6 +174,14 @@ public function setFilterArray(array $array):void {
$this->setFilter(\json_encode($array));
}

public function getFormattingArray(): array {
return $this->getArray($this->getFormatting());
}

public function setFormattingArray(array $array): void {
$this->setFormatting(\json_encode($array));
}

private function getSharePermissions(): ?Permissions {
return $this->getOnSharePermissions();
}
Expand Down Expand Up @@ -201,6 +212,7 @@ public function jsonSerialize(): array {
'ownerDisplayName' => $this->ownerDisplayName,
];
$serialisedJson['filter'] = $this->getFilterArray();
$serialisedJson['formatting'] = $this->getFormattingArray();

return $serialisedJson;
}
Expand Down
69 changes: 69 additions & 0 deletions lib/Migration/Version2200Date20260425000000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?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 OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;

class Version2200Date20260425000000 extends SimpleMigrationStep {

#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();

$this->addFormattingColumnToViews($schema);
$this->createFormattingRuleColsTable($schema);

return $schema;
}

private function addFormattingColumnToViews(ISchemaWrapper $schema): void {
if (!$schema->hasTable('tables_views')) {
return;
}

$table = $schema->getTable('tables_views');

if (!$table->hasColumn('formatting')) {
$table->addColumn('formatting', Types::TEXT, [
'notnull' => false,
'default' => null,
]);
}
}

private function createFormattingRuleColsTable(ISchemaWrapper $schema): void {
if ($schema->hasTable('tables_fmt_rule_cols')) {
return;
}

$table = $schema->createTable('tables_fmt_rule_cols');
$table->addColumn('rule_id', Types::STRING, [
'length' => 36,
'notnull' => true,
]);
$table->addColumn('view_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->addColumn('column_id', Types::BIGINT, [
'unsigned' => true,
'notnull' => true,
]);
$table->setPrimaryKey(['rule_id', 'column_id']);
$table->addIndex(['column_id'], 'fmt_rulecols_col');
$table->addIndex(['view_id'], 'fmt_rulecols_view');
}
}
75 changes: 75 additions & 0 deletions lib/Model/FormattingConditionGroupInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Model;

use InvalidArgumentException;

class FormattingConditionGroupInput {
private const VALID_OPERATORS = [
'eq', 'neq', 'gt', 'lt', 'gte', 'lte', 'between',
'contains', 'startsWith', 'isEmpty', 'isNotEmpty',
'in', 'before', 'after', 'isToday', 'isThisWeek',
'isTrue', 'isFalse',
];

private const MAX_CONDITIONS = 20;

/** @param list<array{columnId: int, columnType: string, operator: string, value?: scalar, values?: list<float|int|string>}> $conditions */
private function __construct(
private readonly array $conditions,
) {
}

public static function createFromInputArray(array $data): self {
if (!isset($data['conditions']) || !is_array($data['conditions'])) {
throw new InvalidArgumentException('conditions must be an array');
}
if (count($data['conditions']) > self::MAX_CONDITIONS) {
throw new InvalidArgumentException('Max ' . self::MAX_CONDITIONS . ' conditions per group');
}

$conditions = [];
foreach ($data['conditions'] as $raw) {
if (!is_array($raw)) {
throw new InvalidArgumentException('Each condition must be an array');
}
if (!isset($raw['columnId'], $raw['columnType'], $raw['operator'])) {
throw new InvalidArgumentException('Condition requires columnId, columnType and operator');
}
if (!in_array((string)$raw['operator'], self::VALID_OPERATORS, true)) {
throw new InvalidArgumentException('Unknown operator: ' . $raw['operator']);
}

$condition = [
'columnId' => (int)$raw['columnId'],
'columnType' => (string)$raw['columnType'],
'operator' => (string)$raw['operator'],
];
if (array_key_exists('value', $raw)) {
$condition['value'] = $raw['value'];
}
if (array_key_exists('values', $raw) && is_array($raw['values'])) {
$condition['values'] = array_values($raw['values']);
}
$conditions[] = $condition;
}

return new self($conditions);
}

public function toArray(): array {
return ['conditions' => $this->conditions];
}

/** @return int[] */
public function collectColumnIds(): array {
return array_column($this->conditions, 'columnId');
}
}
62 changes: 62 additions & 0 deletions lib/Model/FormattingConditionSetInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

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

namespace OCA\Tables\Model;

use InvalidArgumentException;

class FormattingConditionSetInput {
private const MAX_GROUPS = 10;

/** @param list<FormattingConditionGroupInput> $groups */
private function __construct(
private readonly array $groups,
) {
}

public static function createFromInputArray(array $data): self {
if (!isset($data['groups']) || !is_array($data['groups'])) {
throw new InvalidArgumentException('groups must be an array');
}
if (empty($data['groups'])) {
throw new InvalidArgumentException('At least one condition group is required');
}
if (count($data['groups']) > self::MAX_GROUPS) {
throw new InvalidArgumentException('Max ' . self::MAX_GROUPS . ' groups per condition set');
}

$groups = [];
foreach ($data['groups'] as $groupData) {
if (!is_array($groupData)) {
throw new InvalidArgumentException('Each group must be an array');
}
$groups[] = FormattingConditionGroupInput::createFromInputArray($groupData);
}

return new self($groups);
}

public function toArray(): array {
return [
'groups' => array_map(
static fn (FormattingConditionGroupInput $g) => $g->toArray(),
$this->groups
),
];
}

/** @return int[] */
public function collectColumnIds(): array {
$ids = [];
foreach ($this->groups as $group) {
$ids = array_merge($ids, $group->collectColumnIds());
}
return array_values(array_unique($ids));
}
}
Loading
Loading