diff --git a/lib/Constants/ViewUpdatableParameters.php b/lib/Constants/ViewUpdatableParameters.php index f991af4525..97624c091c 100644 --- a/lib/Constants/ViewUpdatableParameters.php +++ b/lib/Constants/ViewUpdatableParameters.php @@ -16,4 +16,5 @@ enum ViewUpdatableParameters: string { case SORT = 'sort'; case FILTER = 'filter'; case COLUMN_SETTINGS = 'columns'; + case SIDEBAR_ORDER = 'sidebarOrder'; } diff --git a/lib/Db/View.php b/lib/Db/View.php index 34783f76ef..4dbe12ac1a 100644 --- a/lib/Db/View.php +++ b/lib/Db/View.php @@ -61,10 +61,13 @@ * @method setOwnerDisplayName(string $ownerDisplayName) * @method getOwnership(): ?string * @method setOwnership(string $ownership) + * @method getSidebarOrder(): ?int + * @method setSidebarOrder(?int $sidebarOrder) */ class View extends EntitySuper implements JsonSerializable { protected ?string $title = null; protected ?int $tableId = null; + protected ?int $sidebarOrder = null; protected ?string $createdBy = null; protected ?string $createdAt = null; protected ?string $lastEditBy = null; @@ -89,6 +92,7 @@ class View extends EntitySuper implements JsonSerializable { public function __construct() { $this->addType('id', 'integer'); $this->addType('tableId', 'integer'); + $this->addType('sidebarOrder', 'integer'); } /** @@ -199,6 +203,7 @@ public function jsonSerialize(): array { 'hasShares' => (bool)$this->hasShares, 'rowsCount' => $this->rowsCount ?: 0, 'ownerDisplayName' => $this->ownerDisplayName, + 'sidebarOrder' => $this->sidebarOrder, ]; $serialisedJson['filter'] = $this->getFilterArray(); diff --git a/lib/Db/ViewMapper.php b/lib/Db/ViewMapper.php index 531803be5f..2cbedf78f5 100644 --- a/lib/Db/ViewMapper.php +++ b/lib/Db/ViewMapper.php @@ -111,6 +111,10 @@ public function findAll(?int $tableId = null): array { if ($tableId !== null) { $qb->where($qb->expr()->eq('v.table_id', $qb->createNamedParameter($tableId, IQueryBuilder::PARAM_INT))); } + + $qb->addOrderBy('v.sidebar_order', 'ASC'); + $qb->addOrderBy('v.id', 'ASC'); + return $this->findEntities($qb); } diff --git a/lib/Migration/Version2210Date20260709000000.php b/lib/Migration/Version2210Date20260709000000.php new file mode 100644 index 0000000000..31536364e3 --- /dev/null +++ b/lib/Migration/Version2210Date20260709000000.php @@ -0,0 +1,39 @@ +hasTable('tables_views')) { + return null; + } + + $table = $schema->getTable('tables_views'); + if (!$table->hasColumn('sidebar_order')) { + $table->addColumn('sidebar_order', Types::BIGINT, [ + 'notnull' => false, + 'default' => null, + ]); + } + + return $schema; + } +} diff --git a/lib/Model/ViewUpdateInput.php b/lib/Model/ViewUpdateInput.php index 3e01ef9789..d02cd6f3f9 100644 --- a/lib/Model/ViewUpdateInput.php +++ b/lib/Model/ViewUpdateInput.php @@ -29,10 +29,14 @@ public function __construct( protected readonly ?ColumnSettings $columnSettings = null, protected readonly ?FilterSet $filterSet = null, protected readonly ?SortRuleSet $sortRuleSet = null, + protected readonly ?int $sidebarOrder = null, ) { } public function updateDetail(): Generator { + if ($this->sidebarOrder !== null) { + yield ViewUpdatableParameters::SIDEBAR_ORDER => $this->sidebarOrder; + } if ($this->title) { yield ViewUpdatableParameters::TITLE => $this->title; } @@ -61,7 +65,8 @@ public function updateDetail(): Generator { * columns?: list, * columnSettings?: list, * sort?: list, - * filter?: list> + * filter?: list>, + * sidebarOrder?: int * } $data */ public static function fromInputArray(array $data): self { @@ -87,6 +92,7 @@ public static function fromInputArray(array $data): self { columnSettings: ($data['columnSettings'] ?? null) ? ColumnSettings::createViewSettingsFromInputArray($data['columnSettings']) : null, filterSet: ($data['filter'] ?? null) ? FilterSet::createFromInputArray($data['filter']) : null, sortRuleSet: ($data['sort'] ?? null) ? SortRuleSet::createFromInputArray($data['sort']) : null, + sidebarOrder: (array_key_exists('sidebarOrder', $data) && $data['sidebarOrder'] !== null) ? (int)$data['sidebarOrder'] : null, ); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 0e6c40ef1c..654d2bbac8 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -41,6 +41,7 @@ * }, * hasShares: bool, * rowsCount: int, + * sidebarOrder: int|null, * } * * @psalm-type TablesTable = array{ diff --git a/lib/Service/ViewService.php b/lib/Service/ViewService.php index 8c5ad15890..9ba6cc68b6 100644 --- a/lib/Service/ViewService.php +++ b/lib/Service/ViewService.php @@ -245,6 +245,8 @@ public function update(int $id, ViewUpdateInput $data, ?string $userId = null, b } foreach ($data->updateDetail() as $parameter => $value) { + $insertableValue = null; + if ($parameter === ViewUpdatableParameters::COLUMN_SETTINGS && $value instanceof ColumnSettings ) { diff --git a/src/modules/navigation/partials/NavigationTableItem.vue b/src/modules/navigation/partials/NavigationTableItem.vue index 04b4a06ca0..e7c4eb793c 100644 --- a/src/modules/navigation/partials/NavigationTableItem.vue +++ b/src/modules/navigation/partials/NavigationTableItem.vue @@ -128,8 +128,14 @@
    - +
@@ -207,6 +213,9 @@ export default { data() { return { isParentOfActiveView: false, + orderedViews: [], + draggedIndex: null, + dragOverIndex: null, } }, @@ -219,13 +228,28 @@ export default { return getCurrentUser().uid }, getViews() { - return this.views.filter(v => v.tableId === this.table.id && v.title.toLowerCase().includes(this.filterString.toLowerCase())) + return this.views + .filter(v => v.tableId === this.table.id && v.title.toLowerCase().includes(this.filterString.toLowerCase())) + .sort((a, b) => { + const orderA = a.sidebarOrder ?? Number.MAX_SAFE_INTEGER + const orderB = b.sidebarOrder ?? Number.MAX_SAFE_INTEGER + return orderA - orderB || a.id - b.id + }) }, hasViews() { return this.getViews.length > 0 }, + canReorderViews() { + return this.canManageElement(this.table) && !this.filterString && this.orderedViews.length > 1 + }, }, watch: { + getViews: { + handler(views) { + this.orderedViews = [...views] + }, + immediate: true, + }, activeView() { if (!this.isParentOfActiveView && this.activeView?.tableId === this.table?.id) { this.isParentOfActiveView = true @@ -238,8 +262,47 @@ export default { }, }, methods: { - ...mapActions(useTablesStore, ['favoriteTable', 'removeFavoriteTable', 'updateTable']), + ...mapActions(useTablesStore, ['favoriteTable', 'removeFavoriteTable', 'updateTable', 'updateView']), emit, + onViewDragStart(index) { + if (!this.canReorderViews) { + return + } + this.draggedIndex = index + }, + onViewDragOver(index) { + if (this.draggedIndex === null || this.draggedIndex === index) { + return + } + const moved = this.orderedViews.splice(this.draggedIndex, 1)[0] + this.orderedViews.splice(index, 0, moved) + this.draggedIndex = index + this.dragOverIndex = index + }, + onViewDrop() { + this.persistViewOrder() + }, + onViewDragEnd() { + this.persistViewOrder() + }, + async persistViewOrder() { + if (this.draggedIndex === null) { + this.dragOverIndex = null + return + } + this.draggedIndex = null + this.dragOverIndex = null + + const updates = [] + this.orderedViews.forEach((view, index) => { + if (view.sidebarOrder !== index) { + updates.push(this.updateView({ id: view.id, data: { data: { sidebarOrder: index } } })) + } + }) + if (updates.length) { + await Promise.all(updates) + } + }, deleteTable() { emit('tables:table:delete', this.table) }, @@ -343,4 +406,8 @@ export default { display: inline; } } + +.view-drop-target { + border-top: 2px solid var(--color-primary-element); +} diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 09f39a6266..a784bb6cab 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1238,6 +1238,8 @@ export type components = { readonly hasShares: boolean; /** Format: int64 */ readonly rowsCount: number; + /** Format: int64 */ + readonly sidebarOrder: number | null; }; }; responses: never;