Skip to content

Commit

Permalink
atomicdata-dev#850 Add d&d to resource array input
Browse files Browse the repository at this point in the history
  • Loading branch information
Polleps authored and joepio committed Feb 29, 2024
1 parent 30ae484 commit 73fd0fc
Show file tree
Hide file tree
Showing 5 changed files with 277 additions and 72 deletions.
1 change: 1 addition & 0 deletions browser/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This changelog covers all three packages, as they are (for now) updated as a who

- [#841](https://github.com/atomicdata-dev/atomic-server/issues/841) Add better inputs for `Timestamp` and `Date` datatypes.
- [#842](https://github.com/atomicdata-dev/atomic-server/issues/842) Add media picker for properties with classtype file.
- [#850](https://github.com/atomicdata-dev/atomic-server/issues/850) Add drag & drop sorting to ResourceArray inputs.

## v0.37.0

Expand Down
216 changes: 193 additions & 23 deletions browser/data-browser/src/components/forms/InputResourceArray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@ import { ArrayError, useArray, validateDatatype } from '@tomic/react';
import { Button } from '../Button';
import { InputProps } from './ResourceField';
import { ErrMessage } from './InputStyles';
import { ResourceSelector } from './ResourceSelector';
import { FaPlus, FaTrash } from 'react-icons/fa';
import { ResourceSelector, ResourceSelectorProps } from './ResourceSelector';
import { Column, Row } from '../Row';
import { styled } from 'styled-components';
import { useIndexDependantCallback } from '../../hooks/useIndexDependantCallback';
import {
DndContext,
DragEndEvent,
DragOverlay,
useDraggable,
useDroppable,
} from '@dnd-kit/core';
import { transition } from '../../helpers/transition';
import { FaGripVertical, FaPlus, FaTrash } from 'react-icons/fa6';
import { createPortal } from 'react-dom';

interface InputResourceArrayProps extends InputProps {
isA?: string;
Expand All @@ -20,22 +29,18 @@ export default function InputResourceArray({
...props
}: InputResourceArrayProps): JSX.Element {
const [err, setErr] = useState<ArrayError | undefined>(undefined);
const [draggingSubject, setDraggingSubject] = useState<string>();
const [array, setArray] = useArray(resource, property.subject, {
validate: false,
commit,
});

/** Add focus to the last added item */
const [lastIsNew, setLastIsNew] = useState(false);

function handleAddRow() {
setArray([...array, undefined]);
setLastIsNew(true);
}

function handleClear() {
setArray([]);
setLastIsNew(false);
}

const handleRemoveRowList = useIndexDependantCallback(
Expand All @@ -57,7 +62,6 @@ export default function InputResourceArray({
try {
validateDatatype(newArray, property.datatype);
setArray(newArray);
setLastIsNew(false);
setErr(undefined);
} catch (e) {
setErr(e);
Expand All @@ -68,6 +72,21 @@ export default function InputResourceArray({
[property.datatype, setArray],
);

const handleDragEnd = ({ active, over }: DragEndEvent) => {
setDraggingSubject(undefined);

if (!over) {
return;
}

const oldPos = array.indexOf(active.id as string);
const newPos = over.id as number;
const newArray = [...array];
const [removed] = newArray.splice(oldPos, 1);
newArray.splice(newPos > oldPos ? newPos - 1 : newPos, 0, removed);
setArray(newArray);
};

const errMaybe = useCallback(
(index: number) => {
if (err && err.index === index) {
Expand All @@ -82,21 +101,55 @@ export default function InputResourceArray({
return (
<Column>
{array.length > 0 && (
<div>
{array.map((subject, index) => (
<ResourceSelector
key={`${property.subject}${index}`}
value={subject}
setSubject={handleSetSubjectList[index]}
error={errMaybe(index)}
isA={property.classType}
handleRemove={handleRemoveRowList[index]}
parent={resource.getSubject()}
{...props}
autoFocus={lastIsNew && index === array.length - 1}
/>
))}
</div>
<DndContext
onDragStart={event => setDraggingSubject(event.active.id as string)}
onDragCancel={() => setDraggingSubject(undefined)}
onDragEnd={handleDragEnd}
>
<RelativeContainer>
<DropEdge visible={!!draggingSubject} index={0} />
{array.map((subject, index) => (
<>
<DraggableResourceSelector
first={index === 0}
last={index === array.length - 1}
subject={subject}
key={`${property.subject}${index}`}
value={subject}
setSubject={handleSetSubjectList[index]}
error={errMaybe(index)}
isA={property.classType}
handleRemove={handleRemoveRowList[index]}
parent={resource.getSubject()}
hideClearButton
{...props}
/>
{!(subject === undefined && index === array.length - 1) && (
<DropEdge visible={!!draggingSubject} index={index + 1} />
)}
</>
))}
{createPortal(
<StyledDragOverlay>
{!!draggingSubject && (
<DummySelector
first
last
id={draggingSubject}
value={draggingSubject}
setSubject={() => undefined}
isA={property.classType}
handleRemove={() => undefined}
hideClearButton
parent={resource.getSubject()}
{...props}
/>
)}
</StyledDragOverlay>,
document.body,
)}
</RelativeContainer>
</DndContext>
)}
{!props.disabled && (
<Row justify='space-between'>
Expand Down Expand Up @@ -129,6 +182,123 @@ export default function InputResourceArray({
);
}

interface DropEdgeProps {
index: number;
visible: boolean;
}

const DropEdge = ({ index, visible }: DropEdgeProps) => {
const { setNodeRef, isOver } = useDroppable({
id: index,
});

return <DropEdgeElement ref={setNodeRef} active={isOver} visible={visible} />;
};

type DraggableResourceSelectorProps = ResourceSelectorProps & {
subject: string;
};

const DraggableResourceSelector = ({
subject,
...props
}: DraggableResourceSelectorProps) => {
const { attributes, listeners, setNodeRef, active } = useDraggable({
id: subject,
});

if (subject === undefined) {
return <ResourceSelector {...props} />;
}

return (
<DragWrapper ref={setNodeRef} active={active?.id === subject}>
<ResourceSelector
{...props}
prefix={
<DragHandle
{...listeners}
{...attributes}
type='button'
title='Move item'
>
<FaGripVertical />
</DragHandle>
}
/>
</DragWrapper>
);
};

const DummySelector = (props: ResourceSelectorProps) => {
return (
<DragWrapper active={false}>
<ResourceSelector
{...props}
prefix={
<DragHandle type='button'>
<FaGripVertical />
</DragHandle>
}
/>
</DragWrapper>
);
};

const StyledDragOverlay = styled(DragOverlay)`
opacity: 0.8;
cursor: grabbing;
`;

const RelativeContainer = styled.div`
position: relative;
`;

const DragHandle = styled.button`
display: flex;
align-items: center;
cursor: grab;
border-radius: ${p => p.theme.radius};
appearance: none;
background: transparent;
border: none;
&:active {
cursor: grabbing;
svg {
color: ${p => p.theme.colors.textLight};
}
}
svg {
color: ${p => p.theme.colors.textLight2};
}
`;
const DragWrapper = styled(Row)<{ active: boolean }>`
position: relative;
opacity: ${p => (p.active ? 0.4 : 1)};
width: 100%;
&:hover {
${DragHandle} svg {
color: ${p => p.theme.colors.textLight};
}
}
`;

const StyledButton = styled(Button)`
align-self: flex-start;
`;

const DropEdgeElement = styled.div<{ visible: boolean; active: boolean }>`
display: ${p => (p.visible ? 'block' : 'none')};
position: absolute;
height: 3px;
border-radius: 1.5px;
transform: scaleX(${p => (p.active ? 1.1 : 1)});
background: ${p => p.theme.colors.main};
opacity: ${p => (p.active ? 1 : 0)};
z-index: 2;
width: 100%;
${transition('opacity', 'transform')}
`;
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { useState, useMemo, memo } from 'react';
import { Dialog, useDialog } from '../../Dialog';
import { useDialogTreeContext } from '../../Dialog/dialogContext';
import { useSettings } from '../../../helpers/AppSettings';
import { styled } from 'styled-components';
import { css, styled } from 'styled-components';
import { NewFormDialog } from '../NewForm/NewFormDialog';
import { SearchBox } from '../SearchBox';
import { SearchBoxButton } from '../SearchBox/SearchBox';
import { FaTrash } from 'react-icons/fa';
import { ErrorChip } from '../ErrorChip';
import { urls } from '@tomic/react';
import { SearchBoxButton } from '../SearchBox/SearchBoxButton';

interface ResourceSelectorProps {
export interface ResourceSelectorProps {
/**
* This callback is called when the Subject Changes. You can pass an Error
* Handler as the second argument to set an error message. Take the second
Expand All @@ -34,6 +34,15 @@ interface ResourceSelectorProps {
/** Is used when a new item is created using the ResourceSelector */
parent?: string;
hideCreateOption?: boolean;
hideClearButton?: boolean;

/** If true, this is the first item in a list, default=true*/
first?: boolean;
/** If true, this is the last item in a list, default=true*/
last?: boolean;

/** Some react node that is displayed in front of the text inside the input wrapper*/
prefix?: React.ReactNode;
}

/**
Expand All @@ -49,7 +58,11 @@ export const ResourceSelector = memo(function ResourceSelector({
isA,
disabled,
parent,
hideClearButton,
hideCreateOption,
first = true,
last = true,
prefix,
}: ResourceSelectorProps): JSX.Element {
const [dialogProps, showDialog, closeDialog, isDialogOpen] = useDialog();
const [initialNewTitle, setInitialNewTitle] = useState('');
Expand All @@ -69,13 +82,15 @@ export const ResourceSelector = memo(function ResourceSelector({
}, [hideCreateOption, setSubject, showDialog, isA]);

return (
<Wrapper>
<Wrapper first={first} last={last}>
<StyledSearchBox
prefix={prefix}
value={value}
onChange={setSubject}
isA={isA}
required={required}
disabled={disabled}
hideClearButton={hideClearButton}
onCreateItem={handleCreateItem}
>
{handleRemove && !disabled && (
Expand Down Expand Up @@ -107,26 +122,28 @@ export const ResourceSelector = memo(function ResourceSelector({
// We need Wrapper to be able to target this component.
const StyledSearchBox = styled(SearchBox)``;

const Wrapper = styled.div`
const Wrapper = styled.div<{ first?: boolean; last?: boolean }>`
--top-radius: ${p => (p.first ? p.theme.radius : 0)};
--bottom-radius: ${p => (p.last ? p.theme.radius : 0)};
flex: 1;
max-width: 100%;
position: relative;
--radius: ${props => props.theme.radius};
${StyledSearchBox} {
border-radius: 0;
}
&:first-of-type ${StyledSearchBox} {
border-top-left-radius: var(--radius);
border-top-right-radius: var(--radius);
}
&:last-of-type ${StyledSearchBox} {
border-bottom-left-radius: var(--radius);
border-bottom-right-radius: var(--radius);
}
& ${StyledSearchBox} {
border-top-left-radius: var(--top-radius);
border-top-right-radius: var(--top-radius);
border-bottom-left-radius: var(--bottom-radius);
border-bottom-right-radius: var(--bottom-radius);
&:not(:last-of-type) ${StyledSearchBox} {
border-bottom: none;
${p =>
!p.last &&
css`
border-bottom: none;
`}
}
`;

Expand Down
Loading

0 comments on commit 73fd0fc

Please sign in to comment.