Use paced mutations for record updates

This commit is contained in:
Hakan Shehu
2025-11-21 11:40:35 -08:00
parent fc8a6a0626
commit 2f59d19d72
16 changed files with 225 additions and 297 deletions

View File

@@ -24,7 +24,7 @@ export const RecordAttributes = () => {
<div className="w-60 max-w-60"> <div className="w-60 max-w-60">
<RecordField field={field} /> <RecordField field={field} />
</div> </div>
<div className="flex-1 max-w-128 p-1"> <div className="flex-1 max-w-lg p-1">
<RecordFieldValue field={field} /> <RecordFieldValue field={field} />
</div> </div>
</div> </div>

View File

@@ -1,8 +1,11 @@
import { debounceStrategy, usePacedMutations } from '@tanstack/react-db';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input'; import { LocalNode } from '@colanode/client/types';
import { Input } from '@colanode/ui/components/ui/input';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { applyNodeTransaction } from '@colanode/ui/lib/nodes';
export const RecordName = () => { export const RecordName = () => {
const workspace = useWorkspace(); const workspace = useWorkspace();
@@ -20,26 +23,32 @@ export const RecordName = () => {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [record.canEdit, inputRef]); }, [record.canEdit, inputRef]);
return ( const mutate = usePacedMutations<string, LocalNode>({
<SmartTextInput onMutate: (value) => {
value={record.name} workspace.collections.nodes.update(record.id, (draft) => {
readOnly={!record.canEdit} if (draft.type !== 'record') {
ref={inputRef}
onChange={(value) => {
if (value === record.name) {
return; return;
} }
const nodes = workspace.collections.nodes; draft.name = value;
nodes.update(record.id, (draft) => { });
if (draft.type !== 'record') { },
return; mutationFn: async ({ transaction }) => {
} await applyNodeTransaction(workspace.userId, transaction);
},
strategy: debounceStrategy({ wait: 500 }),
});
draft.name = value; return (
}); <Input
value={record.name}
readOnly={!record.canEdit}
ref={inputRef}
onChange={(event) => {
const newValue = event.target.value;
mutate(newValue);
}} }}
className="font-heading border-b border-none pl-1 text-4xl font-bold shadow-none focus-visible:ring-0" className="font-heading border-b border-none pl-1 md:text-4xl text-2xl font-bold shadow-none focus-visible:ring-0"
placeholder="Unnamed" placeholder="Unnamed"
/> />
); );

View File

@@ -1,5 +1,3 @@
import { toast } from 'sonner';
import { LocalRecordNode } from '@colanode/client/types'; import { LocalRecordNode } from '@colanode/client/types';
import { NodeRole, hasNodeRole } from '@colanode/core'; import { NodeRole, hasNodeRole } from '@colanode/core';
import { RecordContext } from '@colanode/ui/contexts/record'; import { RecordContext } from '@colanode/ui/contexts/record';
@@ -33,141 +31,6 @@ export const RecordProvider = ({
databaseId: record.databaseId, databaseId: record.databaseId,
localRevision: record.localRevision, localRevision: record.localRevision,
canEdit, canEdit,
updateFieldValue: async (field, value) => {
const nodes = workspace.collections.nodes;
if (!nodes.has(record.id)) {
toast.error('Record not found');
return;
}
nodes.update(record.id, (draft) => {
if (draft.type !== 'record') {
return;
}
draft.fields[field.id] = value;
});
},
removeFieldValue: async (field) => {
const nodes = workspace.collections.nodes;
if (!nodes.has(record.id)) {
toast.error('Record not found');
return;
}
nodes.update(record.id, (draft) => {
if (draft.type !== 'record') {
return;
}
const { [field.id]: _removed, ...rest } = draft.fields;
draft.fields = rest;
});
},
getBooleanValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'boolean') {
return fieldValue.value;
}
return false;
},
getCollaboratorValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string_array') {
return fieldValue.value;
}
return null;
},
getDateValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string') {
return new Date(fieldValue.value);
}
return null;
},
getEmailValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string') {
return fieldValue.value;
}
return null;
},
getFileValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string_array') {
return fieldValue.value;
}
return null;
},
getMultiSelectValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string_array') {
return fieldValue.value;
}
return [];
},
getNumberValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'number') {
return fieldValue.value;
}
return null;
},
getPhoneValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string') {
return fieldValue.value;
}
return null;
},
getRelationValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string_array') {
return fieldValue.value;
}
return null;
},
getRollupValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string') {
return fieldValue.value;
}
return null;
},
getSelectValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string') {
return fieldValue.value;
}
return null;
},
getTextValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'text') {
return fieldValue.value;
}
return null;
},
getUrlValue: (field) => {
const fieldValue = record.fields[field.id];
if (fieldValue?.type === 'string') {
return fieldValue.value;
}
return null;
},
}} }}
> >
{children} {children}

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react'; import { BooleanFieldAttributes, BooleanFieldValue } from '@colanode/core';
import { BooleanFieldAttributes } from '@colanode/core';
import { Checkbox } from '@colanode/ui/components/ui/checkbox'; import { Checkbox } from '@colanode/ui/components/ui/checkbox';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordBooleanValueProps { interface RecordBooleanValueProps {
field: BooleanFieldAttributes; field: BooleanFieldAttributes;
@@ -14,31 +13,27 @@ export const RecordBooleanValue = ({
readOnly, readOnly,
}: RecordBooleanValueProps) => { }: RecordBooleanValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<BooleanFieldValue>({
const [input, setInput] = useState<boolean>(record.getBooleanValue(field)); field,
});
useEffect(() => {
setInput(record.getBooleanValue(field));
}, [record.localRevision]);
return ( return (
<div className="flex h-full w-full flex-row items-center justify-start p-0"> <div className="flex h-full w-full flex-row items-center justify-start p-0">
<Checkbox <Checkbox
checked={input} checked={value?.value ?? false}
disabled={!record.canEdit || readOnly} disabled={!record.canEdit || readOnly}
onCheckedChange={(e) => { onCheckedChange={(e) => {
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
if (typeof e === 'boolean') { if (typeof e === 'boolean') {
setInput(e.valueOf());
const checked = e.valueOf(); const checked = e.valueOf();
if (checked) { if (checked) {
record.updateFieldValue(field, { setValue({
type: 'boolean', type: 'boolean',
value: checked, value: checked,
}); });
} else { } else {
record.removeFieldValue(field); clearValue();
} }
} }
}} }}

View File

@@ -1,8 +1,11 @@
import { inArray, useLiveQuery } from '@tanstack/react-db'; import { inArray, useLiveQuery } from '@tanstack/react-db';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { CollaboratorFieldAttributes } from '@colanode/core'; import {
CollaboratorFieldAttributes,
StringArrayFieldValue,
} from '@colanode/core';
import { Avatar } from '@colanode/ui/components/avatars/avatar'; import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { Badge } from '@colanode/ui/components/ui/badge'; import { Badge } from '@colanode/ui/components/ui/badge';
import { import {
@@ -14,6 +17,7 @@ import { Separator } from '@colanode/ui/components/ui/separator';
import { UserSearch } from '@colanode/ui/components/users/user-search'; import { UserSearch } from '@colanode/ui/components/users/user-search';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface CollaboratorBadgeProps { interface CollaboratorBadgeProps {
id: string; id: string;
@@ -41,10 +45,15 @@ export const RecordCollaboratorValue = ({
}: RecordCollaboratorValueProps) => { }: RecordCollaboratorValueProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringArrayFieldValue>(
{
field,
}
);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const collaboratorIds = record.getCollaboratorValue(field) ?? []; const collaboratorIds = useMemo(() => value?.value ?? [], [value]);
const collaboratorsQuery = useLiveQuery( const collaboratorsQuery = useLiveQuery(
(q) => (q) =>
q q
@@ -115,9 +124,9 @@ export const RecordCollaboratorValue = ({
); );
if (newCollaborators.length === 0) { if (newCollaborators.length === 0) {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string_array', type: 'string_array',
value: newCollaborators, value: newCollaborators,
}); });
@@ -141,9 +150,9 @@ export const RecordCollaboratorValue = ({
: [...collaboratorIds, user.id]; : [...collaboratorIds, user.id];
if (newCollaborators.length === 0) { if (newCollaborators.length === 0) {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string_array', type: 'string_array',
value: newCollaborators, value: newCollaborators,
}); });

View File

@@ -1,6 +1,7 @@
import { DateFieldAttributes } from '@colanode/core'; import { DateFieldAttributes, StringFieldValue } from '@colanode/core';
import { DatePicker } from '@colanode/ui/components/ui/date-picker'; import { DatePicker } from '@colanode/ui/components/ui/date-picker';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordDateValueProps { interface RecordDateValueProps {
field: DateFieldAttributes; field: DateFieldAttributes;
@@ -9,18 +10,21 @@ interface RecordDateValueProps {
export const RecordDateValue = ({ field, readOnly }: RecordDateValueProps) => { export const RecordDateValue = ({ field, readOnly }: RecordDateValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringFieldValue>({
field,
});
return ( return (
<DatePicker <DatePicker
value={record.getDateValue(field)} value={value ? new Date(value.value) : null}
readonly={!record.canEdit || readOnly} readonly={!record.canEdit || readOnly}
onChange={(newValue) => { onChange={(newValue) => {
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
if (newValue === null || newValue === undefined) { if (newValue === null || newValue === undefined) {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string', type: 'string',
value: newValue.toISOString(), value: newValue.toISOString(),
}); });

View File

@@ -1,6 +1,7 @@
import { EmailFieldAttributes } from '@colanode/core'; import { EmailFieldAttributes, StringFieldValue } from '@colanode/core';
import { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input'; import { Input } from '@colanode/ui/components/ui/input';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordEmailValueProps { interface RecordEmailValueProps {
field: EmailFieldAttributes; field: EmailFieldAttributes;
@@ -12,18 +13,22 @@ export const RecordEmailValue = ({
readOnly, readOnly,
}: RecordEmailValueProps) => { }: RecordEmailValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringFieldValue>({
field,
});
return ( return (
<SmartTextInput <Input
value={record.getEmailValue(field)} value={value?.value ?? ''}
readOnly={!record.canEdit || readOnly} readOnly={!record.canEdit || readOnly}
onChange={(newValue) => { onChange={(e) => {
const newValue = e.target.value;
if (!record.canEdit) return; if (!record.canEdit) return;
if (newValue === null || newValue === '') { if (newValue === null || newValue === '') {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string', type: 'string',
value: newValue, value: newValue,
}); });

View File

@@ -1,6 +1,9 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { MultiSelectFieldAttributes } from '@colanode/core'; import {
MultiSelectFieldAttributes,
StringArrayFieldValue,
} from '@colanode/core';
import { SelectFieldOptions } from '@colanode/ui/components/databases/fields/select-field-options'; import { SelectFieldOptions } from '@colanode/ui/components/databases/fields/select-field-options';
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge'; import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
import { import {
@@ -9,6 +12,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@colanode/ui/components/ui/popover'; } from '@colanode/ui/components/ui/popover';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordMultiSelectValueProps { interface RecordMultiSelectValueProps {
field: MultiSelectFieldAttributes; field: MultiSelectFieldAttributes;
@@ -22,17 +26,16 @@ export const RecordMultiSelectValue = ({
const record = useRecord(); const record = useRecord();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState( const { value, setValue, clearValue } = useRecordField<StringArrayFieldValue>(
record.getMultiSelectValue(field) {
field,
}
); );
useEffect(() => {
setSelectedValues(record.getMultiSelectValue(field));
}, [record.localRevision]);
const selectOptions = Object.values(field.options ?? {}); const selectOptions = Object.values(field.options ?? {});
const selectedOptionIds = value?.value ?? [];
const selectedOptions = selectOptions.filter((option) => const selectedOptions = selectOptions.filter((option) =>
selectedValues.includes(option.id) selectedOptionIds.includes(option.id)
); );
if (!record.canEdit || readOnly) { if (!record.canEdit || readOnly) {
@@ -67,20 +70,18 @@ export const RecordMultiSelectValue = ({
<PopoverContent className="w-80 p-1"> <PopoverContent className="w-80 p-1">
<SelectFieldOptions <SelectFieldOptions
field={field} field={field}
values={selectedValues} values={selectedOptionIds}
onSelect={(id) => { onSelect={(id) => {
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
const newValues = selectedValues.includes(id) const newValues = selectedOptionIds.includes(id)
? selectedValues.filter((v) => v !== id) ? selectedOptionIds.filter((v) => v !== id)
: [...selectedValues, id]; : [...selectedOptionIds, id];
setSelectedValues(newValues);
if (newValues.length === 0) { if (newValues.length === 0) {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string_array', type: 'string_array',
value: newValues, value: newValues,
}); });

View File

@@ -1,6 +1,7 @@
import { type NumberFieldAttributes } from '@colanode/core'; import { NumberFieldValue, type NumberFieldAttributes } from '@colanode/core';
import { SmartNumberInput } from '@colanode/ui/components/ui/smart-number-input'; import { Input } from '@colanode/ui/components/ui/input';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordNumberValueProps { interface RecordNumberValueProps {
field: NumberFieldAttributes; field: NumberFieldAttributes;
@@ -12,26 +13,37 @@ export const RecordNumberValue = ({
readOnly, readOnly,
}: RecordNumberValueProps) => { }: RecordNumberValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<NumberFieldValue>({
field,
});
return ( return (
<SmartNumberInput <Input
value={record.getNumberValue(field)} value={value?.value ?? undefined}
readOnly={!record.canEdit || readOnly} readOnly={!record.canEdit || readOnly}
onChange={(newValue) => { onChange={(e) => {
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
if (newValue === record.getNumberValue(field)) { const newStringValue = e.target.value;
if (newStringValue === null || newStringValue === '') {
clearValue();
return; return;
} }
if (newValue === null) { const newValue = parseFloat(newStringValue);
record.removeFieldValue(field); console.log('newValue', newValue, newStringValue);
} else { if (isNaN(newValue)) {
record.updateFieldValue(field, { return;
type: 'number',
value: newValue,
});
} }
if (newValue === value?.value) {
return;
}
setValue({
type: 'number',
value: newValue,
});
}} }}
className="flex h-full w-full cursor-pointer flex-row items-center gap-1 border-none p-0 text-sm focus-visible:cursor-text shadow-none" className="flex h-full w-full cursor-pointer flex-row items-center gap-1 border-none p-0 text-sm focus-visible:cursor-text shadow-none"
/> />

View File

@@ -1,6 +1,7 @@
import { PhoneFieldAttributes } from '@colanode/core'; import { PhoneFieldAttributes, StringFieldValue } from '@colanode/core';
import { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input'; import { Input } from '@colanode/ui/components/ui/input';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordPhoneValueProps { interface RecordPhoneValueProps {
field: PhoneFieldAttributes; field: PhoneFieldAttributes;
@@ -12,22 +13,26 @@ export const RecordPhoneValue = ({
readOnly, readOnly,
}: RecordPhoneValueProps) => { }: RecordPhoneValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringFieldValue>({
field,
});
return ( return (
<SmartTextInput <Input
value={record.getPhoneValue(field)} value={value?.value ?? ''}
readOnly={!record.canEdit || readOnly} readOnly={!record.canEdit || readOnly}
onChange={(newValue) => { onChange={(e) => {
const newValue = e.target.value;
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
if (newValue === record.getPhoneValue(field)) { if (newValue === value?.value) {
return; return;
} }
if (newValue === null || newValue === '') { if (newValue === null || newValue === '') {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string', type: 'string',
value: newValue, value: newValue,
}); });

View File

@@ -1,9 +1,9 @@
import { eq, inArray, useLiveQuery } from '@tanstack/react-db'; import { eq, inArray, useLiveQuery } from '@tanstack/react-db';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { Fragment, useState } from 'react'; import { Fragment, useMemo, useState } from 'react';
import { LocalRecordNode } from '@colanode/client/types'; import { LocalRecordNode } from '@colanode/client/types';
import { RelationFieldAttributes } from '@colanode/core'; import { RelationFieldAttributes, StringArrayFieldValue } from '@colanode/core';
import { Avatar } from '@colanode/ui/components/avatars/avatar'; import { Avatar } from '@colanode/ui/components/avatars/avatar';
import { RecordSearch } from '@colanode/ui/components/records/record-search'; import { RecordSearch } from '@colanode/ui/components/records/record-search';
import { Badge } from '@colanode/ui/components/ui/badge'; import { Badge } from '@colanode/ui/components/ui/badge';
@@ -15,6 +15,7 @@ import {
import { Separator } from '@colanode/ui/components/ui/separator'; import { Separator } from '@colanode/ui/components/ui/separator';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useWorkspace } from '@colanode/ui/contexts/workspace'; import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordRelationValueProps { interface RecordRelationValueProps {
field: RelationFieldAttributes; field: RelationFieldAttributes;
@@ -37,10 +38,15 @@ export const RecordRelationValue = ({
}: RecordRelationValueProps) => { }: RecordRelationValueProps) => {
const workspace = useWorkspace(); const workspace = useWorkspace();
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringArrayFieldValue>(
{
field,
}
);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const relationIds = record.getRelationValue(field) ?? []; const relationIds = useMemo(() => value?.value ?? [], [value]);
const relationsQuery = useLiveQuery( const relationsQuery = useLiveQuery(
(q) => { (q) => {
if (relationIds.length === 0 || !field.databaseId) { if (relationIds.length === 0 || !field.databaseId) {
@@ -100,9 +106,9 @@ export const RecordRelationValue = ({
); );
if (newRelations.length === 0) { if (newRelations.length === 0) {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string_array', type: 'string_array',
value: newRelations, value: newRelations,
}); });
@@ -130,9 +136,9 @@ export const RecordRelationValue = ({
: [...relationIds, selectedRecord.id]; : [...relationIds, selectedRecord.id];
if (newRelations.length === 0) { if (newRelations.length === 0) {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string_array', type: 'string_array',
value: newRelations, value: newRelations,
}); });

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { SelectFieldAttributes } from '@colanode/core'; import { SelectFieldAttributes, StringFieldValue } from '@colanode/core';
import { SelectFieldOptions } from '@colanode/ui/components/databases/fields/select-field-options'; import { SelectFieldOptions } from '@colanode/ui/components/databases/fields/select-field-options';
import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge'; import { SelectOptionBadge } from '@colanode/ui/components/databases/fields/select-option-badge';
import { import {
@@ -9,6 +9,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from '@colanode/ui/components/ui/popover'; } from '@colanode/ui/components/ui/popover';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordSelectValueProps { interface RecordSelectValueProps {
field: SelectFieldAttributes; field: SelectFieldAttributes;
@@ -20,17 +21,12 @@ export const RecordSelectValue = ({
readOnly, readOnly,
}: RecordSelectValueProps) => { }: RecordSelectValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringFieldValue>({
field,
});
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState( const selectedOption = field.options?.[value?.value ?? ''];
record.getSelectValue(field)
);
useEffect(() => {
setSelectedValue(record.getSelectValue(field));
}, [record.localRevision]);
const selectedOption = field.options?.[selectedValue ?? ''];
if (!record.canEdit || readOnly) { if (!record.canEdit || readOnly) {
return ( return (
@@ -64,17 +60,14 @@ export const RecordSelectValue = ({
<PopoverContent className="w-80 p-1"> <PopoverContent className="w-80 p-1">
<SelectFieldOptions <SelectFieldOptions
field={field} field={field}
values={[selectedValue ?? '']} values={[value?.value ?? '']}
onSelect={(id) => { onSelect={(id) => {
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
setSelectedValue(id); if (value?.value === id) {
setOpen(false); clearValue();
if (selectedValue === id) {
record.removeFieldValue(field);
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string', type: 'string',
value: id, value: id,
}); });

View File

@@ -1,6 +1,7 @@
import { TextFieldAttributes } from '@colanode/core'; import { TextFieldAttributes, TextFieldValue } from '@colanode/core';
import { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input'; import { Input } from '@colanode/ui/components/ui/input';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
interface RecordTextValueProps { interface RecordTextValueProps {
field: TextFieldAttributes; field: TextFieldAttributes;
@@ -9,22 +10,26 @@ interface RecordTextValueProps {
export const RecordTextValue = ({ field, readOnly }: RecordTextValueProps) => { export const RecordTextValue = ({ field, readOnly }: RecordTextValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<TextFieldValue>({
field,
});
return ( return (
<SmartTextInput <Input
value={record.getTextValue(field)} value={value?.value ?? ''}
readOnly={!record.canEdit || readOnly} readOnly={!record.canEdit || readOnly}
onChange={(newValue) => { onChange={(e) => {
const newValue = e.target.value;
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
if (newValue === record.getTextValue(field)) { if (newValue === value?.value) {
return; return;
} }
if (newValue === null || newValue === '') { if (newValue === null || newValue === '') {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'text', type: 'text',
value: newValue, value: newValue,
}); });

View File

@@ -1,13 +1,18 @@
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { isValidUrl, UrlFieldAttributes } from '@colanode/core'; import {
isValidUrl,
StringFieldValue,
UrlFieldAttributes,
} from '@colanode/core';
import { import {
HoverCard, HoverCard,
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
} from '@colanode/ui/components/ui/hover-card'; } from '@colanode/ui/components/ui/hover-card';
import { SmartTextInput } from '@colanode/ui/components/ui/smart-text-input'; import { Input } from '@colanode/ui/components/ui/input';
import { useRecord } from '@colanode/ui/contexts/record'; import { useRecord } from '@colanode/ui/contexts/record';
import { useRecordField } from '@colanode/ui/hooks/use-record-field';
import { cn } from '@colanode/ui/lib/utils'; import { cn } from '@colanode/ui/lib/utils';
interface RecordUrlValueProps { interface RecordUrlValueProps {
@@ -17,17 +22,20 @@ interface RecordUrlValueProps {
export const RecordUrlValue = ({ field, readOnly }: RecordUrlValueProps) => { export const RecordUrlValue = ({ field, readOnly }: RecordUrlValueProps) => {
const record = useRecord(); const record = useRecord();
const { value, setValue, clearValue } = useRecordField<StringFieldValue>({
const url = record.getUrlValue(field); field,
});
const url = value?.value ?? '';
const canOpen = url && isValidUrl(url); const canOpen = url && isValidUrl(url);
return ( return (
<HoverCard openDelay={300}> <HoverCard openDelay={300}>
<HoverCardTrigger> <HoverCardTrigger>
<SmartTextInput <Input
value={url} value={url}
readOnly={!record.canEdit || readOnly} readOnly={!record.canEdit || readOnly}
onChange={(newValue) => { onChange={(e) => {
const newValue = e.target.value;
if (!record.canEdit || readOnly) return; if (!record.canEdit || readOnly) return;
if (newValue === url) { if (newValue === url) {
@@ -35,9 +43,9 @@ export const RecordUrlValue = ({ field, readOnly }: RecordUrlValueProps) => {
} }
if (newValue === null || newValue === '') { if (newValue === null || newValue === '') {
record.removeFieldValue(field); clearValue();
} else { } else {
record.updateFieldValue(field, { setValue({
type: 'string', type: 'string',
value: newValue, value: newValue,
}); });
@@ -48,7 +56,7 @@ export const RecordUrlValue = ({ field, readOnly }: RecordUrlValueProps) => {
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent <HoverCardContent
className={cn( className={cn(
'flex w-full min-w-80 max-w-128 flex-row items-center justify-between gap-2 overflow-hidden', 'flex w-full min-w-80 max-w-lg flex-row items-center justify-between gap-2 overflow-hidden',
!canOpen && 'hidden' !canOpen && 'hidden'
)} )}
> >

View File

@@ -1,22 +1,6 @@
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { import { FieldValue } from '@colanode/core';
BooleanFieldAttributes,
CollaboratorFieldAttributes,
DateFieldAttributes,
EmailFieldAttributes,
FieldAttributes,
FieldValue,
FileFieldAttributes,
MultiSelectFieldAttributes,
NumberFieldAttributes,
PhoneFieldAttributes,
RelationFieldAttributes,
RollupFieldAttributes,
SelectFieldAttributes,
TextFieldAttributes,
UrlFieldAttributes,
} from '@colanode/core';
interface RecordContext { interface RecordContext {
id: string; id: string;
@@ -30,21 +14,6 @@ interface RecordContext {
databaseId: string; databaseId: string;
canEdit: boolean; canEdit: boolean;
localRevision: string; localRevision: string;
updateFieldValue: (field: FieldAttributes, value: FieldValue) => void;
removeFieldValue: (field: FieldAttributes) => void;
getBooleanValue: (field: BooleanFieldAttributes) => boolean;
getCollaboratorValue: (field: CollaboratorFieldAttributes) => string[] | null;
getDateValue: (field: DateFieldAttributes) => Date | null;
getEmailValue: (field: EmailFieldAttributes) => string | null;
getFileValue: (field: FileFieldAttributes) => string[] | null;
getMultiSelectValue: (field: MultiSelectFieldAttributes) => string[];
getNumberValue: (field: NumberFieldAttributes) => number | null;
getPhoneValue: (field: PhoneFieldAttributes) => string | null;
getRelationValue: (field: RelationFieldAttributes) => string[] | null;
getRollupValue: (field: RollupFieldAttributes) => string | null;
getSelectValue: (field: SelectFieldAttributes) => string | null;
getTextValue: (field: TextFieldAttributes) => string | null;
getUrlValue: (field: UrlFieldAttributes) => string | null;
} }
export const RecordContext = createContext<RecordContext>({} as RecordContext); export const RecordContext = createContext<RecordContext>({} as RecordContext);

View File

@@ -0,0 +1,44 @@
import { debounceStrategy, usePacedMutations } from '@tanstack/react-db';
import { useCallback, useMemo } from 'react';
import { LocalNode } from '@colanode/client/types';
import { FieldAttributes, FieldValue } from '@colanode/core';
import { useRecord } from '@colanode/ui/contexts/record';
import { useWorkspace } from '@colanode/ui/contexts/workspace';
import { applyNodeTransaction } from '@colanode/ui/lib/nodes';
interface Options {
field: FieldAttributes;
}
export const useRecordField = <T extends FieldValue>({ field }: Options) => {
const record = useRecord();
const workspace = useWorkspace();
const mutate = usePacedMutations<T | null, LocalNode>({
onMutate: (nextValue) => {
workspace.collections.nodes.update(record.id, (draft) => {
if (draft.type !== 'record') return;
if (nextValue === null) {
const { [field.id]: _removed, ...rest } = draft.fields;
draft.fields = rest;
} else {
draft.fields[field.id] = nextValue;
}
});
},
mutationFn: async ({ transaction }) => {
await applyNodeTransaction(workspace.userId, transaction);
},
strategy: debounceStrategy({ wait: 500 }),
});
const value = useMemo(() => {
return (record.fields[field.id] as T | undefined) ?? null;
}, [record.fields, field.id]) as T | null;
const setValue = useCallback((next: T) => mutate(next), [mutate]);
const clearValue = useCallback(() => mutate(null), [mutate]);
return { value, setValue, clearValue };
};