datajunction-ui 0.0.1-a60 → 0.0.1-a62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.1a60",
3
+ "version": "0.0.1a62",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -165,7 +165,7 @@
165
165
  "coverageThreshold": {
166
166
  "global": {
167
167
  "statements": 87,
168
- "branches": 75,
168
+ "branches": 74,
169
169
  "lines": 80,
170
170
  "functions": 85
171
171
  }
@@ -0,0 +1,41 @@
1
+ export default function AddNodeDropdown({ namespace }) {
2
+ return (
3
+ <span className="menu-link" style={{ margin: '0.5em 0 0 1em' }}>
4
+ <span className="menu-title">
5
+ <div className="dropdown">
6
+ <span className="add_node">+ Add Node</span>
7
+ <div className="dropdown-content">
8
+ <a href={`/create/source`}>
9
+ <div className="node_type__source node_type_creation_heading">
10
+ Register Table
11
+ </div>
12
+ </a>
13
+ <a href={`/create/transform/${namespace}`}>
14
+ <div className="node_type__transform node_type_creation_heading">
15
+ Transform
16
+ </div>
17
+ </a>
18
+ <a href={`/create/metric/${namespace}`}>
19
+ <div className="node_type__metric node_type_creation_heading">
20
+ Metric
21
+ </div>
22
+ </a>
23
+ <a href={`/create/dimension/${namespace}`}>
24
+ <div className="node_type__dimension node_type_creation_heading">
25
+ Dimension
26
+ </div>
27
+ </a>
28
+ <a href={`/create/tag`}>
29
+ <div className="entity__tag node_type_creation_heading">Tag</div>
30
+ </a>
31
+ <a href={`/create/cube/${namespace}`}>
32
+ <div className="node_type__cube node_type_creation_heading">
33
+ Cube
34
+ </div>
35
+ </a>
36
+ </div>
37
+ </div>
38
+ </span>
39
+ </span>
40
+ );
41
+ }
@@ -0,0 +1,7 @@
1
+ const FilterIcon = props => (
2
+ <svg height="23px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
3
+ <path d="M20 36h8v-4h-8v4zm-14-24v4h36v-4h-36zm6 14h24v-4h-24v4z" />
4
+ <path d="M0 0h48v48h-48z" fill="none" />
5
+ </svg>
6
+ );
7
+ export default FilterIcon;
@@ -0,0 +1,21 @@
1
+ import { components } from 'react-select';
2
+
3
+ const Control = ({ children, ...props }) => {
4
+ const { label, onLabelClick } = props.selectProps;
5
+ const style = {
6
+ cursor: 'pointer',
7
+ padding: '10px 5px 10px 12px',
8
+ color: 'rgb(112, 110, 115)',
9
+ };
10
+
11
+ return (
12
+ <components.Control {...props}>
13
+ <span onMouseDown={onLabelClick} style={style}>
14
+ {label}
15
+ </span>
16
+ {children}
17
+ </components.Control>
18
+ );
19
+ };
20
+
21
+ export default Control;
@@ -0,0 +1,27 @@
1
+ import Select from 'react-select';
2
+ import Control from './FieldControl';
3
+
4
+ export default function NodeTypeSelect({ onChange }) {
5
+ return (
6
+ <span className="menu-link" style={{ marginLeft: '30px', width: '300px' }}
7
+ data-testid="select-node-type">
8
+ <Select
9
+ name="node_type"
10
+ isClearable
11
+ label="Node Type"
12
+ components={{ Control }}
13
+ onChange={e => onChange(e)}
14
+ styles={{
15
+ control: styles => ({ ...styles, backgroundColor: 'white' }),
16
+ }}
17
+ options={[
18
+ {value: 'source', label: 'Source'},
19
+ {value: 'transform', label: 'Transform'},
20
+ {value: 'dimension', label: 'Dimension'},
21
+ {value: 'metric', label: 'Metric'},
22
+ {value: 'cube', label: 'Cube'},
23
+ ]}
24
+ />
25
+ </span>
26
+ );
27
+ }
@@ -0,0 +1,40 @@
1
+ import { useContext, useEffect, useState } from 'react';
2
+ import DJClientContext from '../../providers/djclient';
3
+ import Control from './FieldControl';
4
+
5
+ import Select from 'react-select';
6
+
7
+ export default function TagSelect({ onChange }) {
8
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
9
+
10
+ const [retrieved, setRetrieved] = useState(false);
11
+ const [tags, setTags] = useState([]);
12
+
13
+ useEffect(() => {
14
+ const fetchData = async () => {
15
+ const tags = await djClient.listTags();
16
+ setTags(tags);
17
+ setRetrieved(true);
18
+ };
19
+ fetchData().catch(console.error);
20
+ }, [djClient]);
21
+
22
+ return (
23
+ <span className="menu-link" style={{ marginLeft: '30px', width: '350px' }} data-testid="select-tag">
24
+ <Select
25
+ name="tags"
26
+ isClearable
27
+ isMulti
28
+ label="Tags"
29
+ components={{ Control }}
30
+ onChange={e => onChange(e)}
31
+ options={tags?.map(tag => {
32
+ return {
33
+ value: tag.name,
34
+ label: tag.display_name,
35
+ };
36
+ })}
37
+ />
38
+ </span>
39
+ );
40
+ }
@@ -0,0 +1,43 @@
1
+ import { useContext, useEffect, useState } from 'react';
2
+ import DJClientContext from '../../providers/djclient';
3
+ import Control from './FieldControl';
4
+
5
+ import Select from 'react-select';
6
+
7
+ export default function UserSelect({ onChange, currentUser }) {
8
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
9
+ const [retrieved, setRetrieved] = useState(false);
10
+ const [users, setUsers] = useState([]);
11
+
12
+ useEffect(() => {
13
+ const fetchData = async () => {
14
+ const users = await djClient.users();
15
+ setUsers(users);
16
+ setRetrieved(true);
17
+ };
18
+ fetchData().catch(console.error);
19
+ }, [djClient]);
20
+
21
+ return (
22
+ <span className="menu-link" style={{ marginLeft: '30px', width: '400px' }} data-testid="select-user">
23
+ {retrieved ? (
24
+ <Select
25
+ name="edited_by"
26
+ isClearable
27
+ label="Edited By"
28
+ components={{ Control }}
29
+ onChange={e => onChange(e)}
30
+ defaultValue={{
31
+ value: currentUser,
32
+ label: currentUser,
33
+ }}
34
+ options={users?.map(user => {
35
+ return { value: user.username, label: user.username };
36
+ })}
37
+ />
38
+ ) : (
39
+ ''
40
+ )}
41
+ </span>
42
+ );
43
+ }
@@ -9,6 +9,9 @@ const mockDjClient = {
9
9
  namespaces: jest.fn(),
10
10
  namespace: jest.fn(),
11
11
  addNamespace: jest.fn(),
12
+ whoami: jest.fn(),
13
+ users: jest.fn(),
14
+ listTags: jest.fn(),
12
15
  };
13
16
 
14
17
  describe('NamespacePage', () => {
@@ -34,6 +37,9 @@ describe('NamespacePage', () => {
34
37
 
35
38
  beforeEach(() => {
36
39
  fetch.resetMocks();
40
+ mockDjClient.whoami.mockResolvedValue({username: 'dj'});
41
+ mockDjClient.users.mockResolvedValue([{username: 'dj'}, {username: 'user1'}]);
42
+ mockDjClient.listTags.mockResolvedValue([{name: 'tag1'}, {name: 'tag2'}]);
37
43
  mockDjClient.namespaces.mockResolvedValue([
38
44
  {
39
45
  namespace: 'common.one',
@@ -75,6 +81,8 @@ describe('NamespacePage', () => {
75
81
  type: 'transform',
76
82
  mode: 'active',
77
83
  updated_at: new Date(),
84
+ tags: [{name: 'tag1'}],
85
+ edited_by: ['dj'],
78
86
  },
79
87
  ]);
80
88
  });
@@ -111,6 +119,30 @@ describe('NamespacePage', () => {
111
119
  // check that it renders nodes
112
120
  expect(screen.getByText('Test Node')).toBeInTheDocument();
113
121
 
122
+ // check that it sorts nodes
123
+ fireEvent.click(screen.getByText('name'));
124
+ fireEvent.click(screen.getByText('display name'));
125
+
126
+ // check that we can filter by node type
127
+ const selectNodeType = screen.getAllByTestId('select-node-type')[0];
128
+ expect(selectNodeType).toBeDefined();
129
+ expect(selectNodeType).not.toBeNull();
130
+ fireEvent.keyDown(selectNodeType.firstChild, { key: 'ArrowDown' });
131
+ fireEvent.click(screen.getByText('Source'));
132
+
133
+ // check that we can filter by tag
134
+ const selectTag = screen.getAllByTestId('select-tag')[0];
135
+ expect(selectTag).toBeDefined();
136
+ expect(selectTag).not.toBeNull();
137
+ fireEvent.keyDown(selectTag.firstChild, { key: 'ArrowDown' });
138
+
139
+ // check that we can filter by user
140
+ const selectUser = screen.getAllByTestId('select-user')[0];
141
+ expect(selectUser).toBeDefined();
142
+ expect(selectUser).not.toBeNull();
143
+ fireEvent.keyDown(selectUser.firstChild, { key: 'ArrowDown' });
144
+ // fireEvent.click(screen.getByText('dj'));
145
+
114
146
  // click to open and close tab
115
147
  fireEvent.click(screen.getByText('common'));
116
148
  fireEvent.click(screen.getByText('common'));
@@ -4,11 +4,24 @@ import { useContext, useEffect, useState } from 'react';
4
4
  import NodeStatus from '../NodePage/NodeStatus';
5
5
  import DJClientContext from '../../providers/djclient';
6
6
  import Explorer from '../NamespacePage/Explorer';
7
+ import AddNodeDropdown from '../../components/AddNodeDropdown';
7
8
  import NodeListActions from '../../components/NodeListActions';
8
9
  import AddNamespacePopover from './AddNamespacePopover';
10
+ import FilterIcon from '../../icons/FilterIcon';
11
+ import LoadingIcon from '../../icons/LoadingIcon';
12
+ import UserSelect from './UserSelect';
13
+ import NodeTypeSelect from './NodeTypeSelect';
14
+ import TagSelect from './TagSelect';
15
+
9
16
  import 'styles/node-list.css';
17
+ import 'styles/sorted-table.css';
10
18
 
11
19
  export function NamespacePage() {
20
+ const ASC = 'ascending';
21
+ const DESC = 'descending';
22
+
23
+ const fields = ['name', 'display_name', 'type', 'status', 'updated_at'];
24
+
12
25
  const djClient = useContext(DJClientContext).DataJunctionAPI;
13
26
  var { namespace } = useParams();
14
27
 
@@ -16,9 +29,68 @@ export function NamespacePage() {
16
29
  namespace: namespace,
17
30
  nodes: [],
18
31
  });
32
+ const [retrieved, setRetrieved] = useState(false);
33
+ const [currentUser, setCurrentUser] = useState(null);
34
+
35
+ const [filters, setFilters] = useState({
36
+ tags: [],
37
+ node_type: '',
38
+ edited_by: currentUser?.username,
39
+ });
19
40
 
20
41
  const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
21
42
 
43
+ const [sortConfig, setSortConfig] = useState({
44
+ key: 'updated_at',
45
+ direction: DESC,
46
+ });
47
+ const sortedNodes = React.useMemo(() => {
48
+ let sortableData = [...Object.values(state.nodes)];
49
+ if (filters.node_type !== '' && filters.node_type !== null) {
50
+ sortableData = sortableData.filter(
51
+ node => node.type === filters.node_type,
52
+ );
53
+ }
54
+ if (filters.tags) {
55
+ sortableData = sortableData.filter(node => {
56
+ const nodeTags = node.tags.map(tag => tag.name);
57
+ return filters.tags.every(item => nodeTags.includes(item));
58
+ });
59
+ }
60
+ if (filters.edited_by) {
61
+ sortableData = sortableData.filter(node => {
62
+ return node.edited_by.includes(filters.edited_by);
63
+ });
64
+ }
65
+ if (sortConfig !== null) {
66
+ sortableData.sort((a, b) => {
67
+ if (a[sortConfig.key] < b[sortConfig.key]) {
68
+ return sortConfig.direction === ASC ? -1 : 1;
69
+ }
70
+ if (a[sortConfig.key] > b[sortConfig.key]) {
71
+ return sortConfig.direction === ASC ? 1 : -1;
72
+ }
73
+ return 0;
74
+ });
75
+ }
76
+ return sortableData;
77
+ }, [state.nodes, filters, sortConfig]);
78
+
79
+ const requestSort = key => {
80
+ let direction = ASC;
81
+ if (sortConfig.key === key && sortConfig.direction === ASC) {
82
+ direction = DESC;
83
+ }
84
+ setSortConfig({ key, direction });
85
+ };
86
+
87
+ const getClassNamesFor = name => {
88
+ if (sortConfig.key === name) {
89
+ return sortConfig.direction;
90
+ }
91
+ return undefined;
92
+ };
93
+
22
94
  const createNamespaceHierarchy = namespaceList => {
23
95
  const hierarchy = [];
24
96
 
@@ -52,6 +124,8 @@ export function NamespacePage() {
52
124
  const namespaces = await djClient.namespaces();
53
125
  const hierarchy = createNamespaceHierarchy(namespaces);
54
126
  setNamespaceHierarchy(hierarchy);
127
+ const currentUser = await djClient.whoami();
128
+ setCurrentUser(currentUser);
55
129
  };
56
130
  fetchData().catch(console.error);
57
131
  }, [djClient, djClient.namespaces]);
@@ -67,95 +141,106 @@ export function NamespacePage() {
67
141
  namespace: namespace,
68
142
  nodes: foundNodes,
69
143
  });
144
+ setRetrieved(true);
70
145
  };
71
146
  fetchData().catch(console.error);
72
147
  }, [djClient, namespace, namespaceHierarchy]);
73
148
 
74
- const nodesList = state.nodes.map(node => (
75
- <tr>
76
- <td>
77
- <a href={'/nodes/' + node.name} className="link-table">
78
- {node.name}
79
- </a>
80
- <span
81
- className="rounded-pill badge bg-secondary-soft"
82
- style={{ marginLeft: '0.5rem' }}
83
- >
84
- {node.version}
85
- </span>
86
- </td>
87
- <td>
88
- <a href={'/nodes/' + node.name} className="link-table">
89
- {node.type !== 'source' ? node.display_name : ''}
90
- </a>
91
- </td>
92
- <td>
93
- <span className={'node_type__' + node.type + ' badge node_type'}>
94
- {node.type}
95
- </span>
96
- </td>
97
- <td>
98
- <NodeStatus node={node} revalidate={false} />
99
- </td>
100
- <td>
101
- <span className="status">{node.mode}</span>
102
- </td>
103
- <td>
104
- <span className="status">
105
- {new Date(node.updated_at).toLocaleString('en-us')}
106
- </span>
107
- </td>
108
- <td>
109
- <NodeListActions nodeName={node?.name} />
110
- </td>
111
- </tr>
112
- ));
149
+ const nodesList = retrieved ? (
150
+ sortedNodes.map(node => (
151
+ <tr>
152
+ <td>
153
+ <a href={'/nodes/' + node.name} className="link-table">
154
+ {node.name}
155
+ </a>
156
+ <span
157
+ className="rounded-pill badge bg-secondary-soft"
158
+ style={{ marginLeft: '0.5rem' }}
159
+ >
160
+ {node.version}
161
+ </span>
162
+ </td>
163
+ <td>
164
+ <a href={'/nodes/' + node.name} className="link-table">
165
+ {node.type !== 'source' ? node.display_name : ''}
166
+ </a>
167
+ </td>
168
+ <td>
169
+ <span className={'node_type__' + node.type + ' badge node_type'}>
170
+ {node.type}
171
+ </span>
172
+ </td>
173
+ <td>
174
+ <NodeStatus node={node} revalidate={false} />
175
+ </td>
176
+ <td>
177
+ <span className="status">
178
+ {new Date(node.updated_at).toLocaleString('en-us')}
179
+ </span>
180
+ </td>
181
+ <td>
182
+ <NodeListActions nodeName={node?.name} />
183
+ </td>
184
+ </tr>
185
+ ))
186
+ ) : (
187
+ <span style={{ display: 'block', marginTop: '2rem' }}>
188
+ <LoadingIcon />
189
+ </span>
190
+ );
113
191
 
114
192
  return (
115
193
  <div className="mid">
116
194
  <div className="card">
117
195
  <div className="card-header">
118
196
  <h2>Explore</h2>
119
-
120
- <span className="menu-link">
121
- <span className="menu-title">
122
- <div className="dropdown">
123
- <span className="add_node">+ Add Node</span>
124
- <div className="dropdown-content">
125
- <a href={`/create/source`}>
126
- <div className="node_type__source node_type_creation_heading">
127
- Register Table
128
- </div>
129
- </a>
130
- <a href={`/create/transform/${namespace}`}>
131
- <div className="node_type__transform node_type_creation_heading">
132
- Transform
133
- </div>
134
- </a>
135
- <a href={`/create/metric/${namespace}`}>
136
- <div className="node_type__metric node_type_creation_heading">
137
- Metric
138
- </div>
139
- </a>
140
- <a href={`/create/dimension/${namespace}`}>
141
- <div className="node_type__dimension node_type_creation_heading">
142
- Dimension
143
- </div>
144
- </a>
145
- <a href={`/create/tag`}>
146
- <div className="entity__tag node_type_creation_heading">
147
- Tag
148
- </div>
149
- </a>
150
- <a href={`/create/cube/${namespace}`}>
151
- <div className="node_type__cube node_type_creation_heading">
152
- Cube
153
- </div>
154
- </a>
155
- </div>
156
- </div>
157
- </span>
158
- </span>
197
+ <div class="menu" style={{ margin: '0 0 20px 0' }}>
198
+ <div
199
+ className="menu-link"
200
+ style={{
201
+ marginTop: '0.7em',
202
+ color: '#777',
203
+ fontFamily: "'Jost'",
204
+ fontSize: '18px',
205
+ marginRight: '10px',
206
+ marginLeft: '15px',
207
+ }}
208
+ >
209
+ <FilterIcon />
210
+ </div>
211
+ <div
212
+ className="menu-link"
213
+ style={{
214
+ marginTop: '0.6em',
215
+ color: '#777',
216
+ fontFamily: "'Jost'",
217
+ fontSize: '18px',
218
+ marginRight: '10px',
219
+ }}
220
+ >
221
+ Filter By
222
+ </div>
223
+ <NodeTypeSelect
224
+ onChange={entry =>
225
+ setFilters({ ...filters, node_type: entry ? entry.value : '' })
226
+ }
227
+ />
228
+ <TagSelect
229
+ onChange={entry =>
230
+ setFilters({
231
+ ...filters,
232
+ tags: entry ? entry.map(tag => tag.value) : [],
233
+ })
234
+ }
235
+ />
236
+ <UserSelect
237
+ onChange={entry =>
238
+ setFilters({ ...filters, edited_by: entry ? entry.value : '' })
239
+ }
240
+ currentUser={currentUser?.username}
241
+ />
242
+ <AddNodeDropdown namespace={namespace} />
243
+ </div>
159
244
  <div className="table-responsive">
160
245
  <div className={`sidebar`}>
161
246
  <span
@@ -167,7 +252,7 @@ export function NamespacePage() {
167
252
  padding: '1rem 1rem 1rem 0',
168
253
  }}
169
254
  >
170
- Namespaces <AddNamespacePopover namespace={namespace}/>
255
+ Namespaces <AddNamespacePopover namespace={namespace} />
171
256
  </span>
172
257
  {namespaceHierarchy
173
258
  ? namespaceHierarchy.map(child => (
@@ -182,12 +267,19 @@ export function NamespacePage() {
182
267
  <table className="card-table table">
183
268
  <thead>
184
269
  <tr>
185
- <th>Name</th>
186
- <th>Display Name</th>
187
- <th>Type</th>
188
- <th>Status</th>
189
- <th>Mode</th>
190
- <th>Last Updated</th>
270
+ {fields.map(field => {
271
+ return (
272
+ <th>
273
+ <button
274
+ type="button"
275
+ onClick={() => requestSort(field)}
276
+ className={'sortable ' + getClassNamesFor(field)}
277
+ >
278
+ {field.replace('_', ' ')}
279
+ </button>
280
+ </th>
281
+ );
282
+ })}
191
283
  <th>Actions</th>
192
284
  </tr>
193
285
  </thead>
@@ -348,7 +348,7 @@ export const DataJunctionAPI = {
348
348
 
349
349
  namespace: async function (nmspce) {
350
350
  return await (
351
- await fetch(`${DJ_URL}/namespaces/${nmspce}/`, {
351
+ await fetch(`${DJ_URL}/namespaces/${nmspce}/?with_edited_by=true`, {
352
352
  credentials: 'include',
353
353
  })
354
354
  ).json();
@@ -453,16 +453,22 @@ export const DataJunctionAPI = {
453
453
  },
454
454
 
455
455
  notebookExportCube: async function (cube) {
456
- return await fetch(`${DJ_URL}/datajunction-clients/python/notebook/?cube=${cube}`, {
457
- credentials: 'include',
458
- });
456
+ return await fetch(
457
+ `${DJ_URL}/datajunction-clients/python/notebook/?cube=${cube}`,
458
+ {
459
+ credentials: 'include',
460
+ },
461
+ );
459
462
  },
460
463
 
461
464
  notebookExportNamespace: async function (namespace) {
462
465
  return await (
463
- await fetch(`${DJ_URL}/datajunction-clients/python/notebook/?namespace=${namespace}`, {
464
- credentials: 'include',
465
- })
466
+ await fetch(
467
+ `${DJ_URL}/datajunction-clients/python/notebook/?namespace=${namespace}`,
468
+ {
469
+ credentials: 'include',
470
+ },
471
+ )
466
472
  ).json();
467
473
  },
468
474
 
@@ -732,6 +738,13 @@ export const DataJunctionAPI = {
732
738
  });
733
739
  return await response.json();
734
740
  },
741
+ users: async function () {
742
+ return await (
743
+ await fetch(`${DJ_URL}/users?with_activity=true`, {
744
+ credentials: 'include',
745
+ })
746
+ ).json();
747
+ },
735
748
  getTag: async function (tagName) {
736
749
  const response = await fetch(`${DJ_URL}/tags/${tagName}`, {
737
750
  method: 'GET',
@@ -426,7 +426,7 @@ describe('DataJunctionAPI', () => {
426
426
  const nmspce = 'sampleNamespace';
427
427
  fetch.mockResponseOnce(JSON.stringify({}));
428
428
  await DataJunctionAPI.namespace(nmspce);
429
- expect(fetch).toHaveBeenCalledWith(`${DJ_URL}/namespaces/${nmspce}/`, {
429
+ expect(fetch).toHaveBeenCalledWith(`${DJ_URL}/namespaces/${nmspce}/?with_edited_by=true`, {
430
430
  credentials: 'include',
431
431
  });
432
432
  });
@@ -289,6 +289,10 @@ tr {
289
289
  grid-gap: 8px;
290
290
  padding: 8px;
291
291
  }
292
+ .table-responsive {
293
+ width: auto;
294
+ grid-template-columns: 200px auto;
295
+ }
292
296
  .table-vertical {
293
297
  display: contents;
294
298
  }
@@ -730,6 +734,11 @@ pre {
730
734
  margin: 0;
731
735
  list-style: none;
732
736
  }
737
+ .menu-link {
738
+ display: inline-grid;
739
+ margin: 0;
740
+ float: right;
741
+ }
733
742
  .menu-item .menu-link {
734
743
  cursor: pointer;
735
744
  align-items: center;
@@ -773,6 +782,7 @@ pre {
773
782
 
774
783
  .card-header h2 {
775
784
  font-family: 'Jost';
785
+ display: inline-block;
776
786
  }
777
787
 
778
788
  .text-gray-400 {
@@ -0,0 +1,15 @@
1
+ .sortable {
2
+ background: none;
3
+ border: none;
4
+ cursor: pointer;
5
+ font-weight: bold;
6
+ font-family: inherit;
7
+ color: inherit;
8
+ text-transform: uppercase;
9
+ }
10
+ .sortable.ascending::after {
11
+ content: ' ▲';
12
+ }
13
+ .sortable.descending::after {
14
+ content: ' ▼';
15
+ }