Skip to content
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
11 changes: 11 additions & 0 deletions src/i18n/en/virtual-lab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const messages = {
RenameVirtualLabSucceed: 'The virtual lab has been successfully renamed.',
RenameVirtualLabFailed: 'Failed to rename the virtual lab.',
RenameVirtualLabNameAlreadyExists:
'A Virtual Lab with this name already exists. Please choose a different name.',
RenameVirtualLabNameValidationFailed: 'Name validation failed, please try again.',
RenameVirtualLabFailedTitle: 'Failed to rename the virtual lab.',
RenameVirtualLabFailedDescription: 'Please try again or contact support if the issue persists.',
RenameVirtualLabSucceedTitle: 'Update virtual lab',
RenameVirtualLabSucceedDescription: 'The virtual lab has been successfully renamed.',
};
325 changes: 317 additions & 8 deletions src/ui/segments/virtual-lab-settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,329 @@
'use client';

import { CloseOutlined } from '@ant-design/icons';
import { CloseOutlined, EditOutlined, CheckOutlined, CheckCircleOutlined } from '@ant-design/icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { match } from 'ts-pattern';
import { useState } from 'react';
import { z } from 'zod';

import { updateVirtualLab, checkVirtualLabExists } from '@/api/virtual-lab-svc/queries/virtual-lab';
import { CustomPopover } from '@/features/entities/neuron-simulation/experiment/elements/popover';
import { PillTabs, PillTabsList, PillTabsTrigger } from '@/ui/molecules/tabs';
import { TeamTable } from '@/ui/segments/virtual-lab-settings/sections/team';
import { Credits } from '@/ui/segments/virtual-lab-settings/sections/credits';
import { useDefaultBreakpoint } from '@/ui/hooks/create-break-point';
import { useAppNotification } from '@/components/notification';
import { keyBuilder } from '@/ui/use-query-keys/workspace';
import { useTabs } from '@/components/detail-view-tabs';
import { useUserRole } from '@/hooks/use-user-role';
import { messages } from '@/i18n/en/virtual-lab';
import { Button } from '@/ui/molecules/button';
import { cn } from '@/utils/css-class';

import type { VirtualLab } from '@/api/virtual-lab-svc/queries/types';
import type { VirtualLab, VirtualLabListResponse } from '@/api/virtual-lab-svc/queries/types';

const baseNameSchema = z
.string()
.trim()
.min(1, 'Name is required')
.max(100, 'Name must be less than 100 characters');

function buildAsyncNameSchema(currentName: string) {
return baseNameSchema.superRefine(async (val: string, ctx: z.RefinementCtx) => {
if (val === currentName.trim()) return;
try {
const exists = await checkVirtualLabExists({ name: val });
if (exists) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: messages.RenameVirtualLabNameAlreadyExists,
});
}
} catch (_e) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: messages.RenameVirtualLabNameValidationFailed,
});
}
});
}

function EditableName({
initialName,
virtualLabId,
}: {
initialName: string;
virtualLabId: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editingValue, setEditingValue] = useState(initialName);
const [currentName, setCurrentName] = useState(initialName);
const [validation, setValidation] = useState<{
isValid: boolean;
message: string | null;
errors: Array<string>;
checking: boolean;
}>(() => ({ isValid: true, message: null, errors: [], checking: false }));
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const { error: notifyError, success: notifySuccess } = useAppNotification();
const queryClient = useQueryClient();
const { isAdmin } = useUserRole({ virtualLabId });

const updateMutation = useMutation({
mutationFn: async (name: string) => {
return updateVirtualLab({
virtualLabId,
updatePayload: { name },
});
},
onMutate: async (name) => {
await queryClient.cancelQueries({
queryKey: keyBuilder.listAllLabs(),
});

const previousData = queryClient.getQueryData(
keyBuilder.listAllLabs()
) as VirtualLabListResponse;
queryClient.setQueryData(keyBuilder.listAllLabs(), (old: VirtualLabListResponse) => {
if (!old?.data) return old;
const updatedVirtualLab = {
...old.data.virtual_lab,
name,
};

const updatedMembershipLabs = {
...old.data.membership_labs,
results:
old.data.membership_labs?.results?.map((lab) =>
lab.id === virtualLabId ? { ...lab, name } : lab
) || [],
};

return {
...old,
data: {
...old.data,
virtual_lab: updatedVirtualLab,
membership_labs: updatedMembershipLabs,
},
};
});

return { previousData };
},
onError: (__, _, context: { previousData?: VirtualLabListResponse } | undefined) => {
if (context?.previousData) {
queryClient.setQueryData(keyBuilder.listAllLabs(), context.previousData);
const prevData = context.previousData;
if (prevData?.data) {
const previousName =
prevData.data.virtual_lab?.name ||
prevData.data.membership_labs?.results?.find((lab: any) => lab.id === virtualLabId)
?.name;
if (previousName) {
setCurrentName(previousName);
}
}
} else {
setCurrentName(initialName);
}
notifyError({
message: messages.RenameVirtualLabFailedTitle,
description: messages.RenameVirtualLabFailedDescription,
placement: 'topRight',
key: 'virtual-lab-name-update-error',
});
},
onSuccess: () => {
notifySuccess({
message: messages.RenameVirtualLabSucceedTitle,
description: messages.RenameVirtualLabSucceedDescription,
placement: 'topRight',
key: 'virtual-lab-name-update-success',
});
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: keyBuilder.listAllLabs(),
});
},
});

const handleStartEdit = () => {
setIsEditing(true);
setEditingValue(currentName);
setValidation({ isValid: true, message: null, errors: [], checking: false });
};

const handleCancelEdit = () => {
setIsEditing(false);
setEditingValue(currentName);
setValidation({ isValid: true, message: null, errors: [], checking: false });
};

const handleInputChange = (value: string) => setEditingValue(value);

useEffect(() => {
if (!isEditing) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
const value = editingValue;
const run = async () => {
const sync = baseNameSchema.safeParse(value);
if (!sync.success) {
setValidation({
isValid: false,
message: sync.error.issues[0]?.message || 'Invalid name',
errors: sync.error.issues.map((i: z.ZodIssue) => i.message),
checking: false,
});
return;
}

setValidation((prev) => ({ ...prev, checking: true }));
const asyncSchema = buildAsyncNameSchema(currentName);
const asyncRes = await asyncSchema.safeParseAsync(value);
if (!asyncRes.success) {
setValidation({
isValid: false,
message: asyncRes.error.issues[0]?.message || 'Invalid name',
errors: asyncRes.error.issues.map((i: z.ZodIssue) => i.message),
checking: false,
});
return;
}
setValidation({ isValid: true, message: null, errors: [], checking: false });
};
debounceRef.current = setTimeout(run, 350);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [editingValue, isEditing, currentName]);

const handleSaveEdit = async () => {
const schema = buildAsyncNameSchema(currentName);
const result = await schema.safeParseAsync(editingValue);
if (!result.success) {
setValidation({
isValid: false,
message: result.error.issues[0]?.message || 'Invalid name',
errors: result.error.issues.map((i: z.ZodIssue) => i.message),
checking: false,
});
return;
}

if (result.data === currentName.trim()) {
setIsEditing(false);
return;
}

const newName = result.data;
setCurrentName(newName);
setEditingValue(newName);
setIsEditing(false);
updateMutation.mutate(newName);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};

if (isEditing) {
return (
<div className="flex items-start gap-2">
<div className="flex flex-col gap-1">
<div className="relative">
<input
type="text"
value={editingValue}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
className={cn(
'bg-transparent text-xl font-bold text-white placeholder:text-white/50',
'w-full flex-1 border-b transition-all duration-200 ease-in-out outline-none',
'rounded-t-sm py-0.5 pr-8 placeholder:text-sm placeholder:font-light',
{
'border-primary-4 focus:border-white': validation.isValid,
'border-red-400 focus:border-red-400': !validation.isValid,
}
)}
placeholder="Enter virtual lab name..."
disabled={updateMutation.isPending}
/>
<div className="absolute top-1/2 right-2 -translate-y-1/2">
{editingValue.trim() && !validation.checking && (
<div className="transition-all duration-200 ease-in-out">
{validation.isValid ? (
<CheckCircleOutlined className="animate-fade-in text-secondary-3 text-sm" />
) : (
<CloseOutlined className="animate-fade-in text-destructive text-sm" />
)}
</div>
)}
</div>
</div>
{!validation.checking && !validation.isValid && validation.errors.length > 0 && (
<span className="animate-fade-in text-destructive max-w-56 text-xs">
{validation.message}
</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
type="button"
onClick={handleSaveEdit}
disabled={updateMutation.isPending || !validation.isValid || validation.checking}
className={cn(
'h-8 w-8 rounded-full p-0 transition-all duration-200',
'hover:text-secondary-3 bg-green-500/20',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<CheckOutlined className="text-sm" />
</Button>
<Button
type="button"
onClick={handleCancelEdit}
disabled={updateMutation.isPending}
className={cn(
'h-8 w-8 rounded-full p-0 transition-all duration-200',
'hover:text-destructive bg-red-500/20',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
<CloseOutlined className="text-sm" />
</Button>
</div>
</div>
);
}

return (
<div className="group flex items-center gap-2">
<h2 className="text-3xl font-bold transition-all duration-200 select-none group-hover:text-white/90">
{currentName}
</h2>
{isAdmin && (
<Button
type="button"
onClick={handleStartEdit}
className={cn(
'h-8 w-8 rounded-full p-0 transition-all duration-200',
'hover:text-primary-4 hover:bg-white/10'
)}
>
<EditOutlined className="text-sm" />
</Button>
)}
</div>
);
}

function Header({
onClose,
Expand All @@ -23,14 +332,14 @@ function Header({
onClose: () => void;
virtualLab?: (VirtualLab & { isMine: boolean }) | null;
}) {
const name = virtualLab?.isMine ? 'My virtual lab' : virtualLab?.name;
const subtitle = virtualLab?.isMine ? virtualLab.name : null;
const name = virtualLab?.name || '';
const numberOfProjects = virtualLab?.projects_count ?? 0;
const virtualLabId = virtualLab?.id || '';

return (
<div className="flex items-center justify-between py-4 text-white">
<div className="flex flex-col gap-0.5">
<h2 className="text-3xl font-bold select-none">{name}</h2>
{subtitle && <small className="text-primary-2 text-lg">{subtitle}</small>}
{virtualLabId && <EditableName initialName={name} virtualLabId={virtualLabId} />}
</div>
<div className="flex items-center justify-center gap-1.5">
{!!numberOfProjects && (
Expand Down Expand Up @@ -170,7 +479,7 @@ export function VirtualLabConfiguration({ onClose, payload }: Props) {
id="virtual-lab-settings-header"
className="bg-primary-9 sticky top-0 left-0 z-[1002] px-6 pt-2"
>
<Header onClose={onClose} virtualLab={payload?.data} />
<Header onClose={onClose} virtualLab={payload?.data} key={payload?.data?.id} />
<Tabs id={payload?.virtualLabId} />
</div>
<div
Expand Down