Skip to content

Commit 6bb5673

Browse files
committed
Implement environment drag and drop
1 parent 58b21bb commit 6bb5673

5 files changed

Lines changed: 209 additions & 63 deletions

File tree

‎packages/client/src/environment.tsx‎

Lines changed: 114 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
import { getRouteApi, useRouteContext } from '@tanstack/react-router';
2+
import { Array, HashMap, Match, Option, pipe, Predicate } from 'effect';
23
import { Ulid } from 'id128';
34
import { Suspense, useState } from 'react';
45
import {
5-
Collection,
6+
ListBox as AriaListBox,
7+
ListBoxItem as AriaListBoxItem,
68
Dialog,
79
DialogTrigger,
810
Key,
911
MenuTrigger,
10-
Tab,
11-
TabList,
12-
TabPanel,
13-
Tabs,
12+
ToggleButton,
1413
Tooltip,
1514
TooltipTrigger,
15+
useDragAndDrop,
1616
} from 'react-aria-components';
1717
import { FiMoreHorizontal, FiPlus } from 'react-icons/fi';
1818
import { twJoin } from 'tailwind-merge';
19-
2019
import { EnvironmentListItem } from '@the-dev-tools/spec/environment/v1/environment_pb';
2120
import {
2221
EnvironmentCreateEndpoint,
2322
EnvironmentDeleteEndpoint,
2423
EnvironmentListEndpoint,
24+
EnvironmentMoveEndpoint,
2525
EnvironmentUpdateEndpoint,
2626
} from '@the-dev-tools/spec/meta/environment/v1/environment.endpoints.ts';
2727
import {
@@ -35,6 +35,7 @@ import {
3535
WorkspaceGetEndpoint,
3636
WorkspaceUpdateEndpoint,
3737
} from '@the-dev-tools/spec/meta/workspace/v1/workspace.endpoints.ts';
38+
import { MovePosition } from '@the-dev-tools/spec/resources/v1/resources_pb';
3839
import { Button } from '@the-dev-tools/ui/button';
3940
import { DataTable, useReactTable } from '@the-dev-tools/ui/data-table';
4041
import { GlobalEnvironmentIcon, Spinner, VariableIcon } from '@the-dev-tools/ui/icons';
@@ -45,7 +46,6 @@ import { Select } from '@the-dev-tools/ui/select';
4546
import { tw } from '@the-dev-tools/ui/tailwind-literal';
4647
import { TextField, useEditableTextState } from '@the-dev-tools/ui/text-field';
4748
import { useMutate, useQuery } from '~data-client';
48-
4949
import {
5050
columnActionsCommon,
5151
columnCheckboxField,
@@ -126,25 +126,85 @@ const EnvironmentModal = () => {
126126

127127
const { items: environments } = useQuery(EnvironmentListEndpoint, { workspaceId });
128128

129-
const [selectedKey, setSelectedKey] = useState<Key | null>(null);
129+
const environmentMap = pipe(
130+
Array.map(environments, (_) => [Ulid.construct(_.environmentId).toCanonical(), _] as const),
131+
HashMap.fromIterable,
132+
);
133+
134+
const { global: [global] = [], rest = [] } = Array.groupBy(environments, (_) => (_.isGlobal ? 'global' : 'rest'));
135+
136+
const globalIdCan = pipe(
137+
Option.fromNullable(global),
138+
Option.map((_) => Ulid.construct(_.environmentId).toCanonical()),
139+
Option.getOrUndefined,
140+
);
141+
142+
const [selectedKey, setSelectedKey] = useState<Key | undefined>(globalIdCan);
143+
144+
const environment = pipe(
145+
Option.liftPredicate(selectedKey, Predicate.isString),
146+
Option.flatMap((_) => HashMap.get(environmentMap, _)),
147+
Option.getOrNull,
148+
);
149+
150+
const { dragAndDropHooks } = useDragAndDrop({
151+
getItems: (keys) => [...keys].map((key) => ({ key: key.toString() })),
152+
onReorder: ({ keys, target: { dropPosition, key } }) =>
153+
Option.gen(function* () {
154+
const targetIdCan = yield* Option.liftPredicate(key, Predicate.isString);
155+
156+
const sourceIdCan = yield* pipe(
157+
yield* Option.liftPredicate(keys, (_) => _.size === 1),
158+
Array.fromIterable,
159+
Array.head,
160+
Option.filter(Predicate.isString),
161+
);
162+
163+
const position = yield* pipe(
164+
Match.value(dropPosition),
165+
Match.when('after', () => MovePosition.AFTER),
166+
Match.when('before', () => MovePosition.BEFORE),
167+
Match.option,
168+
);
169+
170+
void dataClient.fetch(EnvironmentMoveEndpoint, {
171+
environmentId: Ulid.fromCanonical(sourceIdCan).bytes,
172+
position,
173+
targetEnvironmentId: Ulid.fromCanonical(targetIdCan).bytes,
174+
workspaceId,
175+
});
176+
}),
177+
renderDropIndicator: () => <div className={tw`relative z-10 h-0 w-full ring ring-violet-700`} />,
178+
});
130179

131180
return (
132181
<Modal>
133182
<Dialog className={tw`h-full outline-hidden`}>
134183
{({ close }) => (
135-
<Tabs
136-
className={tw`flex h-full`}
137-
onSelectionChange={setSelectedKey}
138-
orientation='vertical'
139-
selectedKey={selectedKey}
140-
>
184+
<div className={tw`flex h-full`}>
141185
<div className={tw`flex w-64 flex-col border-r border-slate-200 bg-slate-50 p-4 tracking-tight`}>
142-
<div className={tw`-order-3 mb-4`}>
186+
<div className={tw`mb-4`}>
143187
<div className={tw`mb-0.5 text-sm leading-5 font-semibold text-slate-800`}>Variable Settings</div>
144188
<div className={tw`text-xs leading-4 text-slate-500`}>Manage variables & environment</div>
145189
</div>
146190

147-
<div className={tw`-order-1 mt-3 mb-1 flex items-center justify-between py-0.5`}>
191+
<ToggleButton
192+
className={({ isSelected }) =>
193+
twJoin(
194+
tw`-mx-2 flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 text-sm`,
195+
isSelected && tw`bg-slate-200`,
196+
)
197+
}
198+
isSelected={selectedKey === globalIdCan}
199+
onChange={(isSelected) => {
200+
if (isSelected && globalIdCan) setSelectedKey(globalIdCan);
201+
}}
202+
>
203+
<VariableIcon className={tw`size-4 text-slate-500`} />
204+
<span className={tw`text-md leading-5 font-semibold`}>Global Variables</span>
205+
</ToggleButton>
206+
207+
<div className={tw`mt-3 mb-1 flex items-center justify-between py-0.5`}>
148208
<span className={tw`text-md leading-5 text-slate-400`}>Environments</span>
149209

150210
<TooltipTrigger delay={750}>
@@ -170,58 +230,52 @@ const EnvironmentModal = () => {
170230
</TooltipTrigger>
171231
</div>
172232

173-
<TabList className={tw`contents`} items={environments}>
174-
{(item) => {
175-
const environmentIdCan = Ulid.construct(item.environmentId).toCanonical();
176-
return (
177-
<Tab
178-
className={({ isSelected }) =>
179-
twJoin(
180-
tw`-mx-2 flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 text-sm`,
181-
isSelected && tw`bg-slate-200`,
182-
item.isGlobal && tw`-order-2`,
183-
)
184-
}
185-
id={environmentIdCan}
186-
>
187-
{item.isGlobal ? (
188-
<VariableIcon className={tw`size-4 text-slate-500`} />
189-
) : (
190-
<div
191-
className={tw`
192-
flex size-4 items-center justify-center rounded-sm bg-slate-300 text-xs leading-3
193-
text-slate-500
194-
`}
195-
>
196-
{item.name[0]}
197-
</div>
198-
)}
199-
<span className={tw`text-md leading-5 font-semibold`}>
200-
{item.isGlobal ? 'Global Variables' : item.name}
201-
</span>
202-
</Tab>
203-
);
233+
<AriaListBox
234+
aria-label='Environments'
235+
dragAndDropHooks={dragAndDropHooks}
236+
items={rest}
237+
onSelectionChange={(keys) => {
238+
if (!Predicate.isSet(keys) || keys.size !== 1) return;
239+
const [key] = keys.values();
240+
setSelectedKey(key);
204241
}}
205-
</TabList>
242+
selectedKeys={Array.fromNullable(selectedKey)}
243+
selectionMode='single'
244+
>
245+
{(_) => (
246+
<AriaListBoxItem
247+
className={({ isSelected }) =>
248+
twJoin(
249+
tw`-mx-2 flex cursor-pointer items-center gap-1.5 rounded-md px-3 py-1.5 text-sm`,
250+
isSelected && tw`bg-slate-200`,
251+
)
252+
}
253+
id={Ulid.construct(_.environmentId).toCanonical()}
254+
textValue={_.name}
255+
>
256+
<div
257+
className={tw`
258+
flex size-4 items-center justify-center rounded-sm bg-slate-300 text-xs leading-3 text-slate-500
259+
`}
260+
>
261+
{_.name[0]}
262+
</div>
263+
<span className={tw`text-md leading-5 font-semibold`}>{_.name}</span>
264+
</AriaListBoxItem>
265+
)}
266+
</AriaListBox>
206267
</div>
207268

208269
<div className={tw`flex h-full min-w-0 flex-1 flex-col`}>
209-
<Collection items={environments}>
210-
{(_) => {
211-
const id = Ulid.construct(_.environmentId).toCanonical();
212-
return <EnvironmentPanel environment={_} id={id} />;
213-
}}
214-
</Collection>
215-
270+
{environment && <EnvironmentPanel environment={environment} />}
216271
<div className={tw`flex-1`} />
217-
218272
<div className={tw`flex justify-end gap-2 border-t border-slate-200 px-6 py-3`}>
219273
<Button onPress={close} variant='primary'>
220274
Close
221275
</Button>
222276
</div>
223277
</div>
224-
</Tabs>
278+
</div>
225279
)}
226280
</Dialog>
227281
</Modal>
@@ -230,10 +284,9 @@ const EnvironmentModal = () => {
230284

231285
interface EnvironmentPanelProps {
232286
environment: EnvironmentListItem;
233-
id: string;
234287
}
235288

236-
const EnvironmentPanel = ({ environment: { environmentId, isGlobal, name }, id }: EnvironmentPanelProps) => {
289+
const EnvironmentPanel = ({ environment: { environmentId, isGlobal, name } }: EnvironmentPanelProps) => {
237290
const { dataClient } = useRouteContext({ from: '__root__' });
238291

239292
const [environmentUpdate, environmentUpdateLoading] = useMutate(EnvironmentUpdateEndpoint);
@@ -246,7 +299,7 @@ const EnvironmentPanel = ({ environment: { environmentId, isGlobal, name }, id }
246299
});
247300

248301
return (
249-
<TabPanel className={tw`h-full px-6 py-4`} id={id}>
302+
<div className={tw`h-full px-6 py-4`}>
250303
<div className={tw`mb-4 flex items-center gap-2`} onContextMenu={onContextMenu}>
251304
{isGlobal ? (
252305
<VariableIcon className={tw`size-6 text-slate-500`} />
@@ -304,7 +357,7 @@ const EnvironmentPanel = ({ environment: { environmentId, isGlobal, name }, id }
304357
>
305358
<VariablesTable environmentId={environmentId} />
306359
</Suspense>
307-
</TabPanel>
360+
</div>
308361
);
309362
};
310363

‎packages/server/internal/api/renv/renv.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,9 @@ func CheckOwnerEnv(ctx context.Context, su suser.UserService, es senv.EnvService
187187
}
188188
return su.CheckUserBelongsToWorkspace(ctx, userID, env.WorkspaceID)
189189
}
190+
191+
// TODO: implement move RPC
192+
func (c *EnvRPC) EnvironmentMove(ctx context.Context, req *connect.Request[environmentv1.EnvironmentMoveRequest]) (*connect.Response[environmentv1.EnvironmentMoveResponse], error) {
193+
resp := &environmentv1.EnvironmentMoveResponse{}
194+
return connect.NewResponse(resp), nil
195+
}

‎packages/spec/api/environment.tsp‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,18 @@ model Environment {
2929
@visibility(Resource.Mutation.None) isGlobal: boolean;
3030
}
3131

32+
@autoFields
33+
model EnvironmentMoveRequest {
34+
...ParentKeyOf<Environment>;
35+
...KeyOf<Environment>;
36+
position: Resource.MovePosition;
37+
targetEnvironmentId: Resource.Id;
38+
}
39+
40+
model EnvironmentMoveResponse {}
41+
3242
@Protobuf.service
33-
interface EnvironmentService extends Resource.CRUD<Environment> {}
43+
interface EnvironmentService extends Resource.CRUD<Environment> {
44+
@endpoint("environment.js:move")
45+
EnvironmentMove(...EnvironmentMoveRequest): EnvironmentMoveResponse;
46+
}

‎packages/spec/api/workspace.tsp‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ model WorkspaceMoveRequest {
3636
targetWorkspaceId: Resource.Id;
3737
}
3838

39+
model WorkspaceMoveResponse {}
40+
3941
enum MemberRole {
4042
MEMBER_ROLE_UNSPECIFIED: 0,
4143
MEMBER_ROLE_BASIC: 1,
@@ -68,5 +70,5 @@ interface WorkspaceService
6870
Resource.Update.Interface<WorkspaceMember, TRequest = WorkspaceMemberUpdateRequest>,
6971
Resource.Delete.Interface<WorkspaceMember, TRequest = WorkspaceMemberDeleteRequest> {
7072
@endpoint("workspace.js:move")
71-
WorkspaceMove(...WorkspaceMoveRequest): WellKnown.Empty;
73+
WorkspaceMove(...WorkspaceMoveRequest): WorkspaceMoveResponse;
7274
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { create } from '@bufbuild/protobuf';
2+
import { Endpoint, schema } from '@data-client/endpoint';
3+
import { Array, Equivalence, Match, Option, pipe, Record } from 'effect';
4+
import { EnvironmentMoveRequestSchema, EnvironmentService } from '../dist/buf/typescript/environment/v1/environment_pb';
5+
import { MovePosition } from '../dist/buf/typescript/resources/v1/resources_pb';
6+
import { EnvironmentEntity } from '../dist/meta/environment/v1/environment.entities';
7+
import { MakeEndpointProps } from './resource';
8+
import { createMethodKeyRecord, EndpointProps, makeEndpointFn, makeKey } from './utils';
9+
10+
export const move = ({ method, name }: MakeEndpointProps<typeof EnvironmentService.method.environmentMove>) => {
11+
// TODO: split version spec from example and simplify list schema
12+
const argsKey = (props: EndpointProps<typeof EnvironmentService.method.environmentMove> | null) => {
13+
if (props === null) return {};
14+
const { input, transport } = props;
15+
return createMethodKeyRecord(transport, method, input, ['workspaceId']);
16+
};
17+
18+
const createCollectionFilter =
19+
({ input, transport }: EndpointProps<typeof EnvironmentService.method.environmentMove>) =>
20+
(collectionKey: Record<string, string>) => {
21+
const argsKey = createMethodKeyRecord(transport, method, input, ['workspaceId']);
22+
const compare = Record.getEquivalence(Equivalence.string);
23+
return compare(argsKey, collectionKey);
24+
};
25+
26+
const environmentListSchema = new schema.Collection([EnvironmentEntity], { argsKey, createCollectionFilter });
27+
28+
const endpointFn = async (props: EndpointProps<typeof EnvironmentService.method.environmentMove>) => {
29+
await makeEndpointFn(method)(props);
30+
31+
const snapshot = props.controller().snapshot(props.controller().getState());
32+
33+
// TODO: implement a generic move helper
34+
return Option.gen(function* () {
35+
const Environments = yield* Option.fromNullable(snapshot.get(environmentListSchema, props));
36+
37+
const { environmentId, position, targetEnvironmentId } = create(EnvironmentMoveRequestSchema, props.input);
38+
39+
const offset = yield* pipe(
40+
Match.value(position),
41+
Match.when(MovePosition.AFTER, () => 1),
42+
Match.when(MovePosition.BEFORE, () => 0),
43+
Match.option,
44+
);
45+
46+
const { move = [], rest = [] } = Array.groupBy(Environments, (_) =>
47+
_.environmentId.toString() === environmentId.toString() ? 'move' : 'rest',
48+
);
49+
50+
const index = yield* Array.findFirstIndex(
51+
rest,
52+
(_) => _.environmentId.toString() === targetEnvironmentId.toString(),
53+
);
54+
55+
const [before, after] = Array.splitAt(rest, index + offset);
56+
57+
return [...before, ...move, ...after];
58+
}).pipe(
59+
Option.match({
60+
onNone: () => ({}),
61+
onSome: (_) => ({ items: _ }),
62+
}),
63+
);
64+
};
65+
66+
return new Endpoint(endpointFn, {
67+
key: makeKey(method, name),
68+
name,
69+
schema: { items: environmentListSchema },
70+
sideEffect: true,
71+
});
72+
};

0 commit comments

Comments
 (0)