diff --git a/apps/web/components/edit-record-form/edit-record-form-drawer.tsx b/apps/web/components/edit-record-form/edit-record-form-drawer.tsx index 5ba213fe3..31f7d9833 100644 --- a/apps/web/components/edit-record-form/edit-record-form-drawer.tsx +++ b/apps/web/components/edit-record-form/edit-record-form-drawer.tsx @@ -22,7 +22,7 @@ export const EditRecordFormDrawer: React.FC = ({ table }) => { id: record?.id ?? '', value: table.schema.fields.map((field) => ({ name: field.name.value, - value: record?.values[field.name.value].unpack() ?? null, + value: record?.values[field.name.value]?.unpack() ?? null, })), } diff --git a/apps/web/components/kanban-ui/select-board.tsx b/apps/web/components/kanban-ui/select-board.tsx index 698fab348..7604d5964 100644 --- a/apps/web/components/kanban-ui/select-board.tsx +++ b/apps/web/components/kanban-ui/select-board.tsx @@ -1,26 +1,12 @@ -import type { CollisionDetection, DropAnimation, UniqueIdentifier } from '@dnd-kit/core' +import type { DropAnimation } from '@dnd-kit/core' import { defaultDropAnimationSideEffects } from '@dnd-kit/core' import { DragOverlay } from '@dnd-kit/core' -import { getFirstCollision, pointerWithin, rectIntersection } from '@dnd-kit/core' -import { - DndContext, - closestCenter, - KeyboardSensor, - MouseSensor, - TouchSensor, - useSensor, - useSensors, -} from '@dnd-kit/core' -import { - arrayMove, - horizontalListSortingStrategy, - SortableContext, - sortableKeyboardCoordinates, -} from '@dnd-kit/sortable' +import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core' +import { horizontalListSortingStrategy, SortableContext, sortableKeyboardCoordinates } from '@dnd-kit/sortable' import type { Records, SelectField } from '@egodb/core' import { Container, Group, Modal, useListState } from '@egodb/ui' import { useAtom } from 'jotai' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import { trpc } from '../../trpc' import type { ITableBaseProps } from '../table/table-base-props' import { openKanbanEditFieldAtom } from './kanban-edit-field.atom' @@ -31,6 +17,9 @@ import { CreateNewOptionModal } from './create-new-option-modal' import { groupBy } from '@fxts/core' import { KanbanCard } from './kanban-card' import { UNCATEGORIZED_OPTION_ID } from './kanban.constants' +import { useKanban } from './use-kanban' +import type { Record } from '@egodb/core' +import type { Option } from '@egodb/core' interface IProps extends ITableBaseProps { field: SelectField @@ -49,6 +38,8 @@ const dropAnimation: DropAnimation = { export const SelectBoard: React.FC = ({ table, field, records }) => { const [options, handlers] = useListState(field.options.options) + const containers = options.map((o) => o.id.value) + const [opened, setOpened] = useAtom(openKanbanEditFieldAtom) const groupOptionRecords = () => @@ -60,6 +51,8 @@ export const SelectBoard: React.FC = ({ table, field, records }) => { ) const [optionRecords, setOptionRecords] = useState(groupOptionRecords()) + const reorderOptions = trpc.table.field.select.reorderOptions.useMutation() + useEffect(() => { handlers.setState(field.options.options) }, [field]) @@ -68,7 +61,6 @@ export const SelectBoard: React.FC = ({ table, field, records }) => { setOptionRecords(groupOptionRecords()) }, [records]) - const containers = options.map((o) => o.id.value) const sensors = useSensors( useSensor(MouseSensor), useSensor(TouchSensor), @@ -77,71 +69,6 @@ export const SelectBoard: React.FC = ({ table, field, records }) => { }), ) - const reorderOptions = trpc.table.field.select.reorderOptions.useMutation() - const findContainer = (id: UniqueIdentifier) => { - if (containers.includes(id as string)) { - return id - } - - return Object.keys(optionRecords).find((optionId) => - optionRecords[optionId].map((r) => r.id.value).includes(id as string), - ) - } - - const [activeId, setActiveId] = useState(null) - const lastOverId = useRef(null) - const recentlyMovedToNewContainer = useRef(false) - - const activeContainer = options.find((o) => o.id.value === activeId) - const activeRecord = records.find((r) => r.id.value === activeId) - - const collisionDetectionStrategy: CollisionDetection = useCallback( - (args) => { - if (activeId && activeId in containers) { - return closestCenter({ - ...args, - droppableContainers: args.droppableContainers.filter((container) => container.id in containers), - }) - } - - const pointerIntersections = pointerWithin(args) - const intersections = pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args) - let overId = getFirstCollision(intersections, 'id') - - if (overId != null) { - if (overId in containers) { - const containerItems = optionRecords[overId]?.map((r) => r.id.value) ?? [] - - if (containerItems.length > 0) { - overId = closestCenter({ - ...args, - droppableContainers: args.droppableContainers.filter( - (container) => container.id !== overId && containerItems.includes(container.id as string), - ), - })[0]?.id - } - } - - lastOverId.current = overId - - return [{ id: overId }] - } - - if (recentlyMovedToNewContainer.current) { - lastOverId.current = activeId - } - - return lastOverId.current ? [{ id: lastOverId.current }] : [] - }, - [activeId, containers], - ) - - useEffect(() => { - requestAnimationFrame(() => { - recentlyMovedToNewContainer.current = false - }) - }, [containers]) - const utils = trpc.useContext() const updateRecord = trpc.record.update.useMutation({ onSuccess() { @@ -149,6 +76,39 @@ export const SelectBoard: React.FC = ({ table, field, records }) => { }, }) + const { collisionDetectionStrategy, onDragStart, onDragOver, onDragEnd, isActiveContainer, activeId, activeItem } = + useKanban({ + containers, + items: optionRecords, + setItems: setOptionRecords, + getItemId: (item) => item.id.value, + getActiveItem: (activeId) => records.find((r) => r.id.value === activeId), + + onDragContainerEnd: ({ active, over }) => { + if (over) { + handlers.reorder({ + from: active.data.current?.sortable?.index, + to: over.data.current?.sortable?.index, + }) + + reorderOptions.mutate({ + tableId: table.id.value, + fieldId: field.id.value, + from: active.id as string, + to: over.id as string, + }) + } + }, + onDragItemEnd: (e, overContainer) => { + updateRecord.mutate({ + tableId: table.id.value, + id: e.active.id as string, + value: [{ name: field.name.value, value: overContainer === UNCATEGORIZED_OPTION_ID ? null : overContainer }], + }) + }, + }) + const activeContainer = options.find((o) => o.id.value === activeId) + return ( {opened && ( @@ -166,109 +126,9 @@ export const SelectBoard: React.FC = ({ table, field, records }) => { { - setActiveId(e.active.id) - }} - onDragOver={(e) => { - const { over, active } = e - const overId = over?.id - - if (overId == null || containers.includes(active.id as string)) { - return - } - - const overContainer = findContainer(overId as string) - const activeContainer = findContainer(active.id as string) - if (!activeContainer || !overContainer || activeContainer === overContainer) { - return - } - - setOptionRecords((prev) => { - const activeItems = prev[activeContainer].map((r) => r.id.value) - const overItems = prev[overContainer]?.map((r) => r.id.value) ?? [] - - // Find the indexes for the items - const activeIndex = activeItems.indexOf(active.id as string) - const overIndex = overItems.indexOf(overId as string) - - let newIndex: number - - if (overId in containers) { - newIndex = overItems.length + 1 - } else { - const isBelowOverItem = - over && - active.rect.current.translated && - active.rect.current.translated.top > over.rect.top + over.rect.height - - const modifier = isBelowOverItem ? 1 : 0 - - newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1 - } - - return { - ...prev, - [activeContainer]: [...prev[activeContainer].filter((item) => item.id.value !== active.id)], - [overContainer]: [ - ...(prev[overContainer]?.slice(0, newIndex) ?? []), - optionRecords[activeContainer][activeIndex], - ...(prev[overContainer]?.slice(newIndex, prev[overContainer].length) ?? []), - ], - } - }) - }} - onDragEnd={(e) => { - const { over, active } = e - if (containers.includes(active.id as string) && over?.id) { - handlers.reorder({ - from: active.data.current?.sortable?.index, - to: over.data.current?.sortable?.index, - }) - - reorderOptions.mutate({ - tableId: table.id.value, - fieldId: field.id.value, - from: active.id as string, - to: over.id as string, - }) - return - } - const activeContainer = findContainer(active.id) - - if (!activeContainer) { - setActiveId(null) - return - } - - const overId = over?.id - - if (overId == null) { - setActiveId(null) - return - } - - const overContainer = findContainer(overId) - - if (overContainer) { - const activeIndex = optionRecords[activeContainer].map((r) => r.id.value).indexOf(active.id as string) - const overIndex = optionRecords[overContainer]?.map((r) => r.id.value).indexOf(overId as string) ?? -1 - - if (activeIndex !== overIndex) { - setOptionRecords((items) => ({ - ...items, - [overContainer]: arrayMove(items[overContainer] ?? [], activeIndex, overIndex), - })) - } - updateRecord.mutate({ - tableId: table.id.value, - id: active.id as string, - value: [ - { name: field.name.value, value: overContainer === UNCATEGORIZED_OPTION_ID ? null : overContainer }, - ], - }) - } - setActiveId(null) - }} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDragEnd={onDragEnd} collisionDetection={collisionDetectionStrategy} > = ({ table, field, records }) => { ))} - {containers.includes(activeId as string) ? ( + {isActiveContainer ? ( = ({ table, field, records }) => { id={activeContainer?.id.value ?? ''} /> ) : ( - + )} diff --git a/apps/web/components/kanban-ui/use-kanban.ts b/apps/web/components/kanban-ui/use-kanban.ts new file mode 100644 index 000000000..c3799ad53 --- /dev/null +++ b/apps/web/components/kanban-ui/use-kanban.ts @@ -0,0 +1,188 @@ +import type { CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent, UniqueIdentifier } from '@dnd-kit/core' +import { closestCenter, getFirstCollision, pointerWithin, rectIntersection } from '@dnd-kit/core' +import { arrayMove } from '@dnd-kit/sortable' +import type { Dispatch, SetStateAction } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + +export interface IUseKanbanProps { + containers: UniqueIdentifier[] + items: Record + getActiveItem: (id: UniqueIdentifier) => TItem | undefined + setItems: Dispatch>> + getItemId: (item: TItem) => UniqueIdentifier + + onDragContainerEnd: (e: DragEndEvent) => void + onDragItemEnd: (e: DragEndEvent, overContainer: UniqueIdentifier) => void +} + +export const useKanban = ({ + containers, + items, + setItems, + getItemId, + getActiveItem, + onDragContainerEnd, + onDragItemEnd, +}: IUseKanbanProps) => { + const [activeId, setActiveId] = useState(null) + const lastOverId = useRef(null) + const recentlyMovedToNewContainer = useRef(false) + + const collisionDetectionStrategy: CollisionDetection = useCallback( + (args) => { + if (activeId && activeId in containers) { + return closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter((container) => container.id in containers), + }) + } + + const pointerIntersections = pointerWithin(args) + const intersections = pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args) + let overId = getFirstCollision(intersections, 'id') + + if (overId != null) { + if (overId in containers) { + const containerItems = items[overId]?.map(getItemId) ?? [] + + if (containerItems.length > 0) { + overId = closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => container.id !== overId && containerItems.includes(container.id as string), + ), + })[0]?.id + } + } + + lastOverId.current = overId + + return [{ id: overId }] + } + + if (recentlyMovedToNewContainer.current) { + lastOverId.current = activeId + } + + return lastOverId.current ? [{ id: lastOverId.current }] : [] + }, + [activeId, containers], + ) + + useEffect(() => { + requestAnimationFrame(() => { + recentlyMovedToNewContainer.current = false + }) + }, [containers]) + + const onDragStart: (event: DragStartEvent) => void = (e) => { + setActiveId(e.active.id) + } + + const findContainer = (id: UniqueIdentifier) => { + if (containers.includes(id as string)) { + return id + } + + return Object.keys(items).find((containerId) => items[containerId].map(getItemId).includes(id as string)) + } + + const onDragOver: (event: DragOverEvent) => void = (e) => { + const { over, active } = e + const overId = over?.id + + if (overId == null || containers.includes(active.id as string)) { + return + } + + const overContainer = findContainer(overId as string) + const activeContainer = findContainer(active.id as string) + if (!activeContainer || !overContainer || activeContainer === overContainer) { + return + } + + setItems((prev) => { + const activeItems = prev[activeContainer].map(getItemId) + const overItems = prev[overContainer]?.map(getItemId) ?? [] + + // Find the indexes for the items + const activeIndex = activeItems.indexOf(active.id as string) + const overIndex = overItems.indexOf(overId as string) + + let newIndex: number + + if (overId in containers) { + newIndex = overItems.length + 1 + } else { + const isBelowOverItem = + over && + active.rect.current.translated && + active.rect.current.translated.top > over.rect.top + over.rect.height + + const modifier = isBelowOverItem ? 1 : 0 + + newIndex = overIndex >= 0 ? overIndex + modifier : overItems.length + 1 + } + + return { + ...prev, + [activeContainer]: [...prev[activeContainer].filter((item) => getItemId(item) !== active.id)], + [overContainer]: [ + ...(prev[overContainer]?.slice(0, newIndex) ?? []), + items[activeContainer][activeIndex], + ...(prev[overContainer]?.slice(newIndex, prev[overContainer].length) ?? []), + ], + } + }) + } + + const onDragEnd: (event: DragEndEvent) => void = (e) => { + const { over, active } = e + if (containers.includes(active.id as string) && over?.id) { + onDragContainerEnd(e) + return + } + const activeContainer = findContainer(active.id) + + if (!activeContainer) { + setActiveId(null) + return + } + + const overId = over?.id + + if (overId == null) { + setActiveId(null) + return + } + + const overContainer = findContainer(overId) + + if (overContainer) { + const activeIndex = items[activeContainer].map(getItemId).indexOf(active.id as string) + const overIndex = items[overContainer]?.map(getItemId).indexOf(overId as string) ?? -1 + + if (activeIndex !== overIndex) { + setItems((items) => ({ + ...items, + [overContainer]: arrayMove(items[overContainer] ?? [], activeIndex, overIndex), + })) + } + onDragItemEnd(e, overContainer) + } + setActiveId(null) + } + + const isActiveContainer = containers.includes(activeId as string) + const activeItem = getActiveItem(activeId as string) + + return { + collisionDetectionStrategy, + onDragStart, + onDragOver, + onDragEnd, + isActiveContainer, + activeId, + activeItem, + } +} diff --git a/packages/core/__snapshots__/table.factory.test.ts.snap b/packages/core/__snapshots__/table.factory.test.ts.snap index 394d87852..33a175b5b 100644 --- a/packages/core/__snapshots__/table.factory.test.ts.snap +++ b/packages/core/__snapshots__/table.factory.test.ts.snap @@ -33,6 +33,7 @@ ResultType { }, }, }, + "type": "string", }, ], }, diff --git a/packages/core/__snapshots__/table.test.ts.snap b/packages/core/__snapshots__/table.test.ts.snap index 427373f26..baf5674d5 100644 --- a/packages/core/__snapshots__/table.test.ts.snap +++ b/packages/core/__snapshots__/table.test.ts.snap @@ -32,6 +32,7 @@ Table { }, }, }, + "type": "string", }, StringField { "props": { @@ -51,6 +52,7 @@ Table { }, }, }, + "type": "string", }, ], }, diff --git a/packages/core/field/bool-field.ts b/packages/core/field/bool-field.ts index fd04df6f9..a2fe491c3 100644 --- a/packages/core/field/bool-field.ts +++ b/packages/core/field/bool-field.ts @@ -7,9 +7,7 @@ import type { IBoolField } from './field.type' import { FieldId, FieldName, FieldValueConstraints } from './value-objects' export class BoolField extends BaseField { - get type(): BoolFieldType { - return 'bool' - } + type: BoolFieldType = 'bool' static create(input: ICreateBoolFieldInput): BoolField { return new BoolField({ diff --git a/packages/core/field/date-field.ts b/packages/core/field/date-field.ts index 764bef50d..93c0c4d24 100644 --- a/packages/core/field/date-field.ts +++ b/packages/core/field/date-field.ts @@ -8,9 +8,7 @@ import type { IDateField } from './field.type' import { FieldId, FieldName, FieldValueConstraints } from './value-objects' export class DateField extends BaseField { - get type(): DateType { - return 'date' - } + type: DateType = 'date' static create(input: ICreateDateFieldSchema): DateField { return new DateField({ diff --git a/packages/core/field/date-range-field.ts b/packages/core/field/date-range-field.ts index 0bf194b55..54680ad01 100644 --- a/packages/core/field/date-range-field.ts +++ b/packages/core/field/date-range-field.ts @@ -12,9 +12,7 @@ import type { IDateRangeField } from './field.type' import { FieldId, FieldName, FieldValueConstraints } from './value-objects' export class DateRangeField extends BaseField { - get type(): DateRangeType { - return 'date-range' - } + type: DateRangeType = 'date-range' static create(input: ICreateDateRangeFieldSchema): DateRangeField { return new DateRangeField({ diff --git a/packages/core/field/number-field.ts b/packages/core/field/number-field.ts index ca2295aff..1eeb53fe2 100644 --- a/packages/core/field/number-field.ts +++ b/packages/core/field/number-field.ts @@ -7,9 +7,7 @@ import type { ICreateNumberFieldInput, ICreateNumberFieldValue, NumberType } fro import { FieldId, FieldName, FieldValueConstraints } from './value-objects' export class NumberField extends BaseField { - get type(): NumberType { - return 'number' - } + type: NumberType = 'number' static create(input: ICreateNumberFieldInput): NumberField { return new NumberField({ diff --git a/packages/core/field/select-field.ts b/packages/core/field/select-field.ts index b8ddc806c..f52ad7263 100644 --- a/packages/core/field/select-field.ts +++ b/packages/core/field/select-field.ts @@ -11,9 +11,7 @@ import { WithNewOption, WithOptions } from './specifications/select-field.specif import { FieldId, FieldName, FieldValueConstraints } from './value-objects' export class SelectField extends BaseField { - get type(): SelectFieldType { - return 'select' - } + type: SelectFieldType = 'select' get options() { return this.props.options diff --git a/packages/core/field/string-field.ts b/packages/core/field/string-field.ts index be5dbedb5..688c33ed6 100644 --- a/packages/core/field/string-field.ts +++ b/packages/core/field/string-field.ts @@ -6,9 +6,7 @@ import type { ICreateStringFieldInput, ICreateStringFieldValue, StringFieldType import { FieldId, FieldName, FieldValueConstraints } from './value-objects' export class StringField extends BaseField { - get type(): StringFieldType { - return 'string' - } + type: StringFieldType = 'string' static create(input: ICreateStringFieldInput): StringField { return new StringField({ diff --git a/packages/core/fixtures/table.fixtuer.test.ts b/packages/core/fixtures/table.fixtuer.test.ts index 19b267e7b..710b40d4c 100644 --- a/packages/core/fixtures/table.fixtuer.test.ts +++ b/packages/core/fixtures/table.fixtuer.test.ts @@ -35,6 +35,7 @@ test('createTestTable', () => { }, }, }, + "type": "string", }, ], }, diff --git a/packages/core/record/__snapshots__/record-factory.test.ts.snap b/packages/core/record/__snapshots__/record-factory.test.ts.snap index 3bdf87fd4..2701223a0 100644 --- a/packages/core/record/__snapshots__/record-factory.test.ts.snap +++ b/packages/core/record/__snapshots__/record-factory.test.ts.snap @@ -1,5 +1,40 @@ // Vitest Snapshot v1 +exports[`fromQuery > should create record from query 1`] = ` +{ + "field1": StringFieldValue { + "props": { + "value": "hello", + }, + }, +} +`; + +exports[`fromQuery > should create record from query 2`] = ` +{ + "field1": SelectFieldValue { + "props": { + "value": "opt1", + }, + }, +} +`; + +exports[`fromQuery > should create record from query 3`] = ` +{ + "field1": StringFieldValue { + "props": { + "value": "hello", + }, + }, + "field2": SelectFieldValue { + "props": { + "value": "opt1", + }, + }, +} +`; + exports[`should create record 1`] = ` ResultType { Symbol(Val): Record { diff --git a/packages/core/record/record-factory.test.ts b/packages/core/record/record-factory.test.ts index d38f3a829..0abb96ee7 100644 --- a/packages/core/record/record-factory.test.ts +++ b/packages/core/record/record-factory.test.ts @@ -1,5 +1,8 @@ -import { FieldId, FieldName, FieldValueConstraints, StringField } from '../field' +import { Field, FieldId, FieldName, FieldValueConstraints, SelectField, StringField } from '../field' +import { Option, OptionId, OptionName, Options } from '../option' +import { TableSchemaMap } from '../value-objects' import { RecordFactory } from './record.factory' +import { IQueryRecordSchema } from './record.type' import { WithRecordId, WithRecordTableId } from './specifications' import { RecordCompositeSpecification } from './specifications/interface' @@ -16,8 +19,8 @@ test.each([ }) describe('fromQuery', () => { - test('should create record from query', () => { - const record = RecordFactory.fromQuery( + test.each<[IQueryRecordSchema, TableSchemaMap]>([ + [ { id: 'rec1', tableId: 'tbl1', @@ -36,20 +39,65 @@ describe('fromQuery', () => { }), ], ]), - ) + ], + [ + { + id: 'rec1', + tableId: 'tbl1', + values: { + field1: 'opt1', + }, + createdAt: new Date(), + }, + new Map([ + [ + 'field1', + new SelectField({ + id: FieldId.from('field1'), + name: FieldName.create('field1'), + valueConstrains: FieldValueConstraints.create({}), + options: new Options([new Option({ id: OptionId.fromString('opt1'), name: OptionName.create('opt1') })]), + }), + ], + ]), + ], + [ + { + id: 'rec1', + tableId: 'tbl1', + values: { + field1: 'hello', + field2: 'opt1', + }, + createdAt: new Date(), + }, + new Map([ + [ + 'field1', + new StringField({ + id: FieldId.from('field1'), + name: FieldName.create('field1'), + valueConstrains: FieldValueConstraints.create({}), + }), + ], + [ + 'field2', + new SelectField({ + id: FieldId.from('field2'), + name: FieldName.create('field2'), + valueConstrains: FieldValueConstraints.create({}), + options: new Options([new Option({ id: OptionId.fromString('opt1'), name: OptionName.create('opt1') })]), + }), + ], + ]), + ], + ])('should create record from query', (r, field) => { + const record = RecordFactory.fromQuery(r, field) expect(record.isOk()).toBeTruthy() expect(record.unwrap().values.valueJSON).not.to.be.empty expect(record.unwrap().id.value).to.be.eq('rec1') expect(record.unwrap().tableId.value).to.be.eq('tbl1') - expect(record.unwrap().values.valueJSON).toMatchInlineSnapshot(` - { - "field1": StringFieldValue { - "props": { - "value": "hello", - }, - }, - } - `) + expect(record.unwrap().values.valueJSON).toMatchSnapshot() }) }) diff --git a/packages/core/table.factory.test.ts b/packages/core/table.factory.test.ts index f537e5976..07000f6bb 100644 --- a/packages/core/table.factory.test.ts +++ b/packages/core/table.factory.test.ts @@ -17,5 +17,9 @@ describe('TableFactory', () => { ])('should create table', (input) => { const table = TableFactory.from(input) expect(table).toMatchSnapshot() + expect(table.isOk()).to.be.true + expect(table.unwrap().schema.fields).to.have.length(1) + expect(table.unwrap().schema.fields.at(0)!.type).not.to.be.undefined + expect(table.unwrap().schema.fields.at(0)!.type).to.be.eq('string') }) })