Skip to content

feat: LEAP-1199: Add drag and drop functionality on DM tabs #6079

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

Merged
merged 15 commits into from
Jul 24, 2024
Merged
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
12 changes: 12 additions & 0 deletions label_studio/core/all_urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,18 @@
"name": "data_manager:api:view-reset",
"decorators": ""
},
{
"url": "/api/dm/views/order/",
"module": "data_manager.api.ViewAPI",
"name": "data_manager:api:view-update-order",
"decorators": ""
},
{
"url": "/api/dm/views/order\\.<format>/",
"module": "data_manager.api.ViewAPI",
"name": "data_manager:api:view-update-order",
"decorators": ""
},
{
"url": "/api/dm/views/<pk>/",
"module": "data_manager.api.ViewAPI",
Expand Down
40 changes: 38 additions & 2 deletions label_studio/data_manager/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
from data_manager.managers import get_fields_for_evaluation
from data_manager.models import View
from data_manager.prepare_params import filters_schema, ordering_schema, prepare_params_schema
from data_manager.serializers import DataManagerTaskSerializer, ViewResetSerializer, ViewSerializer
from data_manager.serializers import (
DataManagerTaskSerializer,
ViewOrderSerializer,
ViewResetSerializer,
ViewSerializer,
)
from django.conf import settings
from django.utils.decorators import method_decorator
from django_filters.rest_framework import DjangoFilterBackend
Expand Down Expand Up @@ -163,8 +168,39 @@ def reset(self, request):
queryset.all().delete()
return Response(status=204)

@swagger_auto_schema(
method='post',
tags=['Data Manager'],
operation_summary='Update order of views',
operation_description='Update the order field of views based on the provided list of view IDs',
request_body=ViewOrderSerializer,
)
@action(detail=False, methods=['post'], url_path='order')
def update_order(self, request):
serializer = ViewOrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True)

project_id = serializer.validated_data['project']
view_ids = serializer.validated_data['ids']

project = generics.get_object_or_404(Project.objects.for_user(request.user), pk=project_id)

queryset = self.filter_queryset(self.get_queryset()).filter(project=project)
views = list(queryset.filter(id__in=view_ids))

# Update the order field for each view
view_dict = {view.id: view for view in views}
for order, view_id in enumerate(view_ids):
if view_id in view_dict:
view_dict[view_id].order = order

# Bulk update views
View.objects.bulk_update(views, ['order'])

return Response(status=200)

def get_queryset(self):
return View.objects.filter(project__organization=self.request.user.active_organization).order_by('id')
return View.objects.filter(project__organization=self.request.user.active_organization).order_by('order', 'id')


class TaskPagination(PageNumberPagination):
Expand Down
26 changes: 26 additions & 0 deletions label_studio/data_manager/migrations/0011_auto_20240718_1355.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Generated by Django 3.2.25 on 2024-07-18 13:55

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('data_manager', '0010_auto_20230718_1423'),
]

operations = [
migrations.AlterModelOptions(
name='view',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='view',
name='order',
field=models.IntegerField(default=0, help_text='Position of the tab, starting at the left in data manager and increasing as the tabs go left to right', null=True, verbose_name='order'),
),
migrations.AddIndex(
model_name='view',
index=models.Index(fields=['project', 'order'], name='data_manage_project_69b96e_idx'),
),
]
8 changes: 8 additions & 0 deletions label_studio/data_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
class ViewBaseModel(models.Model):
data = models.JSONField(_('data'), default=dict, null=True, help_text='Custom view data')
ordering = models.JSONField(_('ordering'), default=dict, null=True, help_text='Ordering parameters')
order = models.IntegerField(
_('order'),
default=0,
null=True,
help_text='Position of the tab, starting at the left in data manager and increasing as the tabs go left to right',
)
selected_items = models.JSONField(_('selected items'), default=dict, null=True, help_text='Selected items')
filter_group = models.ForeignKey(
'data_manager.FilterGroup', null=True, on_delete=models.SET_NULL, help_text='Groups of filters'
Expand All @@ -22,6 +28,8 @@ class ViewBaseModel(models.Model):
)

class Meta:
ordering = ['order']
indexes = [models.Index(fields=['project', 'order'])]
abstract = True


Expand Down
7 changes: 7 additions & 0 deletions label_studio/data_manager/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,10 @@ def validate(self, data):

class ViewResetSerializer(serializers.Serializer):
project = serializers.PrimaryKeyRelatedField(queryset=Project.objects.all())


class ViewOrderSerializer(serializers.Serializer):
project = serializers.IntegerField()
ids = serializers.ListField(
child=serializers.IntegerField(), allow_empty=False, help_text='A list of view IDs in the desired order.'
)
33 changes: 33 additions & 0 deletions label_studio/tests/data_manager/test_views_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,36 @@ def test_views_ordered_by_id(business_client, project_id):

ids = [view['id'] for view in data]
assert ids == sorted(ids)


def test_update_views_order(business_client, project_id):
# Create views
views = [{'view_data': 1}, {'view_data': 2}, {'view_data': 3}]

view_ids = []
for view in views:
payload = dict(project=project_id, data=view)
response = business_client.post(
'/api/dm/views/',
data=json.dumps(payload),
content_type='application/json',
)
assert response.status_code == status.HTTP_201_CREATED
view_ids.append(response.json()['id'])

# Update the order of views
new_order = {'project': project_id, 'ids': [view_ids[2], view_ids[0], view_ids[1]]}
response = business_client.post(
'/api/dm/views/order/',
data=json.dumps(new_order),
content_type='application/json',
)
assert response.status_code == status.HTTP_200_OK

# Verify the new order
response = business_client.get('/api/dm/views/')
data = response.json()
assert response.status_code == status.HTTP_200_OK

returned_ids = [view['id'] for view in data]
assert returned_ids == new_order['ids']
24 changes: 21 additions & 3 deletions web/libs/datamanager/src/components/Common/Tabs/Tabs.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { DragDropContext, Droppable } from "react-beautiful-dnd";
import { FaEllipsisV } from "react-icons/fa";
import { BemWithSpecifiContext } from "../../../utils/bem";
import { Button } from "../Button/Button";
Expand All @@ -12,7 +13,16 @@ const { Block, Elem } = BemWithSpecifiContext();

const TabsContext = createContext();

export const Tabs = ({ children, activeTab, onChange, onAdd, tabBarExtraContent, allowedActions, addIcon }) => {
export const Tabs = ({
children,
activeTab,
onChange,
onAdd,
onDragEnd,
tabBarExtraContent,
allowedActions,
addIcon,
}) => {
const [selectedTab, setSelectedTab] = useState(activeTab);

const switchTab = useCallback((tab) => {
Expand All @@ -37,8 +47,16 @@ export const Tabs = ({ children, activeTab, onChange, onAdd, tabBarExtraContent,
<TabsContext.Provider value={contextValue}>
<Block name="tabs">
<Elem name="list">
{children}

<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="droppable" direction="horizontal">
{(provided) => (
<Elem ref={provided.innerRef} name="droppable" {...provided.droppableProps}>
{children}
{provided.placeholder}
</Elem>
)}
</Droppable>
</DragDropContext>
{allowedActions.add !== false && <Elem tag={Button} name="add" type="text" onClick={onAdd} icon={addIcon} />}
</Elem>
<Elem name="extra">{tabBarExtraContent}</Elem>
Expand Down
14 changes: 13 additions & 1 deletion web/libs/datamanager/src/components/Common/Tabs/Tabs.styl
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@
box-shadow 0 -1px 0 #D9D9D9 inset
background var(--sand_200)

&-content
&__draggable
align-items center
width 150px
height 36px
overflow hidden
text-overflow ellipsis

&__droppable
display flex
overflow hidden

&__list
display flex
min-width 0 // don't overgrow

&__item
color rgba(0, 0, 0, 0.5)
width 150px
width 100%
overflow hidden
cursor pointer
position relative
Expand Down
61 changes: 43 additions & 18 deletions web/libs/datamanager/src/components/DataManager/DataManager.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { inject, observer } from "mobx-react";
import React from "react";
import React, { useCallback } from "react";
import { Draggable } from "react-beautiful-dnd";
import { LSPlus } from "../../assets/icons";
import { Block, Elem } from "../../utils/bem";
import { Interface } from "../Common/Interface";
Expand Down Expand Up @@ -66,32 +67,56 @@ const TabsSwitch = switchInjector(
observer(({ sdk, views, tabs, selectedKey }) => {
const editable = sdk.tabControls;

const onDragEnd = useCallback((result) => {
if (!result.destination) {
return;
}

views.updateViewOrder(result.source.index, result.destination.index);
}, []);

return (
<Tabs
activeTab={selectedKey}
onAdd={() => views.addView({ reload: false })}
onChange={(key) => views.setSelected(key)}
onDragEnd={onDragEnd}
tabBarExtraContent={<ProjectSummary />}
addIcon={<LSPlus />}
allowedActions={editable}
>
{tabs.map((tab) => (
<TabsItem
key={tab.key}
tab={tab.key}
title={tab.title}
onFinishEditing={(title) => {
tab.setTitle(title);
tab.save();
}}
onDuplicate={() => tab.parent.duplicateView(tab)}
onClose={() => tab.parent.deleteView(tab)}
onSave={() => tab.virtual && tab.saveVirtual()}
active={tab.key === selectedKey}
editable={tab.editable}
deletable={tab.deletable}
virtual={tab.virtual}
/>
{tabs.map((tab, index) => (
<Draggable key={tab.key} draggableId={tab.key} index={index}>
{(provided, snapshot) => (
<Elem
name={"draggable"}
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
background: snapshot.isDragging && "#ddd",
...provided.draggableProps.style,
}}
>
<TabsItem
key={tab.key}
tab={tab.key}
title={tab.title}
onFinishEditing={(title) => {
tab.setTitle(title);
tab.save();
}}
onDuplicate={() => views.duplicateView(tab)}
onClose={() => views.deleteView(tab)}
onSave={() => tab.virtual && tab.saveVirtual()}
active={tab.key === selectedKey}
editable={tab.editable}
deletable={tab.deletable}
virtual={tab.virtual}
/>
</Elem>
)}
</Draggable>
))}
</Tabs>
);
Expand Down
5 changes: 5 additions & 0 deletions web/libs/datamanager/src/sdk/api-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export const APIConfig = {
method: "patch",
},

orderTab: {
path: "/views/order/",
method: "post",
},

/** Delete particular tab (DELETE) */
deleteTab: {
path: "/views/:tabID",
Expand Down
1 change: 1 addition & 0 deletions web/libs/datamanager/src/stores/AppStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,7 @@ export const AppStore = types
const result = yield self.API[methodName](requestParams, {
headers: requestHeaders,
body: requestBody.body ?? requestBody,
options,
});

if (isAllowCancel) {
Expand Down
17 changes: 17 additions & 0 deletions web/libs/datamanager/src/stores/Tabs/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,23 @@ export const TabStore = types
return view;
}),

updateViewOrder: flow(function* (source, destination) {
// Detach the view from the original position
const [removed] = self.views.splice(source, 1);
const sn = getSnapshot(removed);

// Insert the view at the new position
self.views.splice(destination, 0, sn);

const idList = {
project: getRoot(self).project.id,
ids: self.views.map((obj) => {
return obj.id;
}),
};

getRoot(self).apiCall("orderTab", {}, { body: idList }, { alwaysExpectJSON: false });
}),
duplicateView: flow(function* (view) {
const sn = getSnapshot(view);

Expand Down
5 changes: 3 additions & 2 deletions web/libs/datamanager/src/utils/api-proxy/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,10 @@ export class APIProxy {
* @private
*/
createApiCallExecutor(methodSettings, parentPath, raw = false) {
return async (urlParams, { headers, body } = {}) => {
return async (urlParams, { headers, body, options } = {}) => {
let responseResult;
let responseMeta;
const alwaysExpectJSON = options?.alwaysExpectJSON === undefined ? true : options.alwaysExpectJSON;

try {
const finalParams = {
Expand Down Expand Up @@ -242,7 +243,7 @@ export class APIProxy {
try {
const responseData =
rawResponse.status !== 204
? parseJson(this.alwaysExpectJSON ? responseText : responseText || "{}")
? JSON.parse(this.alwaysExpectJSON && alwaysExpectJSON ? responseText : responseText || "{}")
: { ok: true };

if (methodSettings.convert instanceof Function) {
Expand Down
Loading