Implement database locks (#279)

This commit is contained in:
Hakan Shehu
2026-01-08 14:23:09 +01:00
committed by GitHub
parent 98fe12f90c
commit 0e79e47eb1
17 changed files with 164 additions and 41 deletions

View File

@@ -20,6 +20,7 @@ export const databaseAttributesSchema = z.object({
parentId: z.string(),
fields: z.record(z.string(), fieldAttributesSchema),
nameField: databaseNameFieldAttributesSchema.nullable().optional(),
locked: z.boolean().nullable().optional(),
});
export type DatabaseAttributes = z.infer<typeof databaseAttributesSchema>;

View File

@@ -1,4 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Lock, LockOpen, Trash2 } from 'lucide-react';
import { Fragment, useState } from 'react';
import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input';
@@ -35,12 +35,12 @@ export const BoardViewSettings = () => {
name={view.name}
avatar={view.avatar}
layout={view.layout}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
/>
<ViewRenameInput
id={view.id}
name={view.name}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
/>
</div>
<Separator />
@@ -53,13 +53,30 @@ export const BoardViewSettings = () => {
<div
className="flex cursor-pointer flex-row items-center gap-1 rounded-md p-0.5 hover:bg-accent"
onClick={() => {
setOpenDelete(true);
setOpen(false);
database.toggleLock();
}}
>
<Trash2 className="size-4" />
<span>Delete view</span>
{database.isLocked ? (
<LockOpen className="size-4" />
) : (
<Lock className="size-4" />
)}
<span>
{database.isLocked ? 'Unlock database' : 'Lock database'}
</span>
</div>
{!database.isLocked && (
<div
className="flex cursor-pointer flex-row items-center gap-1 rounded-md p-0.5 hover:bg-accent"
onClick={() => {
setOpenDelete(true);
setOpen(false);
}}
>
<Trash2 className="size-4" />
<span>Delete view</span>
</div>
)}
</div>
</Fragment>
)}

View File

@@ -1,4 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Lock, LockOpen, Trash2 } from 'lucide-react';
import { Fragment, useState } from 'react';
import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input';
@@ -35,12 +35,12 @@ export const CalendarViewSettings = () => {
name={view.name}
avatar={view.avatar}
layout={view.layout}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
/>
<ViewRenameInput
id={view.id}
name={view.name}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
/>
</div>
<Separator />
@@ -53,13 +53,30 @@ export const CalendarViewSettings = () => {
<div
className="flex cursor-pointer flex-row items-center gap-1 rounded-md p-0.5 hover:bg-accent"
onClick={() => {
setOpenDelete(true);
setOpen(false);
database.toggleLock();
}}
>
<Trash2 className="size-4" />
<span>Delete view</span>
{database.isLocked ? (
<LockOpen className="size-4" />
) : (
<Lock className="size-4" />
)}
<span>
{database.isLocked ? 'Unlock database' : 'Lock database'}
</span>
</div>
{!database.isLocked && (
<div
className="flex cursor-pointer flex-row items-center gap-1 rounded-md p-0.5 hover:bg-accent"
onClick={() => {
setOpenDelete(true);
setOpen(false);
}}
>
<Trash2 className="size-4" />
<span>Delete view</span>
</div>
)}
</div>
</Fragment>
)}

View File

@@ -1,5 +1,13 @@
import { Copy, Image, LetterText, Settings, Trash2 } from 'lucide-react';
import { Fragment, useState } from 'react';
import {
Copy,
Image,
LetterText,
Settings,
Trash2,
Lock,
LockOpen,
} from 'lucide-react';
import { Fragment, useCallback, useState } from 'react';
import { LocalDatabaseNode } from '@colanode/client/types';
import { NodeRole, hasNodeRole } from '@colanode/core';
@@ -14,6 +22,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@colanode/ui/components/ui/dropdown-menu';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface DatabaseSettingsProps {
database: LocalDatabaseNode;
@@ -21,11 +30,33 @@ interface DatabaseSettingsProps {
}
export const DatabaseSettings = ({ database, role }: DatabaseSettingsProps) => {
const workspace = useWorkspace();
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [showDeleteDialog, setShowDeleteModal] = useState(false);
const canEdit = hasNodeRole(role, 'editor');
const canDelete = hasNodeRole(role, 'admin');
const isLocked = database.locked ?? false;
const handleLockDatabase = useCallback(() => {
if (!canEdit) {
return;
}
const nodes = workspace.collections.nodes;
if (!nodes.has(database.id)) {
return;
}
nodes.update(database.id, (draft) => {
if (draft.type !== 'database') {
return;
}
const currentLocked = draft.locked ?? false;
draft.locked = !currentLocked;
});
}, [canEdit, database.id, workspace.userId]);
return (
<Fragment>
@@ -64,6 +95,18 @@ export const DatabaseSettings = ({ database, role }: DatabaseSettingsProps) => {
<Image className="size-4" />
Update icon
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
disabled={!canEdit}
onClick={handleLockDatabase}
>
{isLocked ? (
<LockOpen className="size-4" />
) : (
<Lock className="size-4" />
)}
{isLocked ? 'Unlock database' : 'Lock database'}
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center gap-2 cursor-pointer"
disabled

View File

@@ -1,8 +1,9 @@
import { ReactNode } from 'react';
import { ReactNode, useCallback } from 'react';
import { LocalDatabaseNode } from '@colanode/client/types';
import { NodeRole, hasNodeRole } from '@colanode/core';
import { DatabaseContext } from '@colanode/ui/contexts/database';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
interface DatabaseProps {
database: LocalDatabaseNode;
@@ -11,9 +12,28 @@ interface DatabaseProps {
}
export const Database = ({ database, role, children }: DatabaseProps) => {
const workspace = useWorkspace();
const canEdit = hasNodeRole(role, 'editor');
const isLocked = database.locked ?? false;
const canCreateRecord = hasNodeRole(role, 'editor');
const toggleLock = useCallback(() => {
if (!canEdit) {
return;
}
const nodes = workspace.collections.nodes;
nodes.update(database.id, (draft) => {
if (draft.type !== 'database') {
return;
}
const currentLocked = draft.locked ?? false;
draft.locked = !currentLocked;
});
}, [canEdit, database.id, workspace.userId]);
return (
<DatabaseContext.Provider
value={{
@@ -22,9 +42,11 @@ export const Database = ({ database, role, children }: DatabaseProps) => {
nameField: database.nameField,
role,
fields: Object.values(database.fields),
canEdit,
canEdit: canEdit,
isLocked,
canCreateRecord,
rootId: database.rootId,
toggleLock,
}}
>
{children}

View File

@@ -161,7 +161,7 @@ export const FieldCreatePopover = ({
},
});
if (!database.canEdit) {
if (!database.canEdit || database.isLocked) {
return null;
}

View File

@@ -40,7 +40,7 @@ export const FieldRenameInput = ({ field }: FieldRenameInputProps) => {
<div className="w-full p-1">
<Input
value={field.name}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
onChange={(event) => {
const newValue = event.target.value;
mutate(newValue);

View File

@@ -143,7 +143,7 @@ export const TableViewFieldHeader = ({
const [, dragRef] = useDrag<ViewField>({
type: 'table-field-header',
item: viewField,
canDrag: () => database.canEdit,
canDrag: () => database.canEdit && !database.isLocked,
end: (_item, monitor) => {
const dropResult = monitor.getDropResult<{ after: string }>();
if (!dropResult?.after) return;
@@ -190,7 +190,7 @@ export const TableViewFieldHeader = ({
bottomLeft: false,
bottomRight: false,
left: false,
right: database.canEdit,
right: database.canEdit && !database.isLocked,
top: false,
topLeft: false,
topRight: false,

View File

@@ -17,7 +17,7 @@ export const TableViewHeader = () => {
{view.fields.map((field) => {
return <TableViewFieldHeader viewField={field} key={field.field.id} />;
})}
{database.canEdit && (
{database.canEdit && !database.isLocked && (
<FieldCreatePopover
button={<Plus className="ml-2 size-4 cursor-pointer" />}
/>

View File

@@ -74,7 +74,7 @@ export const TableViewNameHeader = () => {
bottomLeft: false,
bottomRight: false,
left: false,
right: database.canEdit,
right: database.canEdit && !database.isLocked,
top: false,
topLeft: false,
topRight: false,
@@ -112,7 +112,7 @@ export const TableViewNameHeader = () => {
<div className="p-1">
<Input
value={database.nameField?.name ?? 'Name'}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
onChange={(e) => {
const newName = e.target.value;
if (newName === database.nameField?.name) return;
@@ -128,7 +128,7 @@ export const TableViewNameHeader = () => {
/>
</div>
<Separator />
{database.canEdit && (
{database.canEdit && !database.isLocked && (
<Fragment>
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-accent rounded-sm"

View File

@@ -1,4 +1,4 @@
import { Trash2 } from 'lucide-react';
import { Lock, LockOpen, Trash2 } from 'lucide-react';
import { Fragment, useState } from 'react';
import { ViewAvatarInput } from '@colanode/ui/components/databases/view-avatar-input';
@@ -35,12 +35,12 @@ export const TableViewSettings = () => {
name={view.name}
avatar={view.avatar}
layout={view.layout}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
/>
<ViewRenameInput
id={view.id}
name={view.name}
readOnly={!database.canEdit}
readOnly={!database.canEdit || database.isLocked}
/>
</div>
<Separator />
@@ -53,13 +53,30 @@ export const TableViewSettings = () => {
<div
className="flex cursor-pointer flex-row items-center gap-1 rounded-md p-0.5 hover:bg-accent"
onClick={() => {
setOpenDelete(true);
setOpen(false);
database.toggleLock();
}}
>
<Trash2 className="size-4" />
<span>Delete view</span>
{database.isLocked ? (
<LockOpen className="size-4" />
) : (
<Lock className="size-4" />
)}
<span>
{database.isLocked ? 'Unlock database' : 'Lock database'}
</span>
</div>
{!database.isLocked && (
<div
className="flex cursor-pointer flex-row items-center gap-1 rounded-md p-0.5 hover:bg-accent"
onClick={() => {
setOpenDelete(true);
setOpen(false);
}}
>
<Trash2 className="size-4" />
<span>Delete view</span>
</div>
)}
</div>
</Fragment>
)}

View File

@@ -147,7 +147,7 @@ export const ViewCreateDialog = ({
onOpenChange(false);
};
if (!database.canEdit) {
if (!database.canEdit || database.isLocked) {
return null;
}

View File

@@ -79,10 +79,12 @@ export const ViewFieldSettings = () => {
<TooltipTrigger>
<span
className={cn(
database.canEdit ? 'cursor-pointer' : 'opacity-50'
database.canEdit && !database.isLocked
? 'cursor-pointer'
: 'opacity-50'
)}
onClick={() => {
if (!database.canEdit) return;
if (!database.canEdit || database.isLocked) return;
handleDisplayChange({
id: field.id,
@@ -103,13 +105,15 @@ export const ViewFieldSettings = () => {
: 'Show field in this view'}
</TooltipContent>
</Tooltip>
{database.canEdit && (
{database.canEdit && !database.isLocked && (
<Tooltip>
<TooltipTrigger asChild>
<Trash2
className={cn(
'size-4',
database.canEdit ? 'cursor-pointer' : 'opacity-50'
database.canEdit && !database.isLocked
? 'cursor-pointer'
: 'opacity-50'
)}
onClick={() => {
setDeleteFieldId(field.id);

View File

@@ -17,7 +17,7 @@ export const ViewTabs = () => {
onClick={() => databaseViews.setActiveViewId(view.id)}
/>
))}
{database.canEdit && <ViewCreateButton />}
{database.canEdit && !database.isLocked && <ViewCreateButton />}
</div>
);
};

View File

@@ -108,7 +108,7 @@ export const View = ({ view }: ViewProps) => {
});
},
initFieldSort: async (fieldId: string, direction: SortDirection) => {
if (!database.canEdit) {
if (!database.canEdit || database.isLocked) {
return;
}

View File

@@ -33,7 +33,7 @@ export const RecordField = ({ field }: RecordFieldProps) => {
<PopoverContent className="ml-1 flex w-72 flex-col gap-1 p-2 text-sm">
<FieldRenameInput field={field} />
<Separator />
{database.canEdit && (
{database.canEdit && !database.isLocked && (
<div
className="flex cursor-pointer flex-row items-center gap-2 p-1 hover:bg-accent"
onClick={() => {

View File

@@ -12,9 +12,11 @@ interface DatabaseContext {
nameField: DatabaseNameFieldAttributes | null | undefined;
fields: FieldAttributes[];
canEdit: boolean;
isLocked: boolean;
canCreateRecord: boolean;
role: NodeRole;
rootId: string;
toggleLock: () => void;
}
export const DatabaseContext = createContext<DatabaseContext>(