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
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
221 changes: 198 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,21 @@ 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';
import { transparentize } from 'polished';

interface InputResourceArrayProps extends InputProps {
isA?: string;
Expand All @@ -20,22 +30,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 +63,6 @@ export default function InputResourceArray({
try {
validateDatatype(newArray, property.datatype);
setArray(newArray);
setLastIsNew(false);
setErr(undefined);
} catch (e) {
setErr(e);
Expand All @@ -68,6 +73,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 +102,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 +183,127 @@ 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,
disabled: props.disabled,
});

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

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

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

const StyledDragOverlay = styled(DragOverlay)`
--search-box-bg: ${p => transparentize(0.5, p.theme.colors.bg)};
backdrop-filter: blur(3px);
`;

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

const DragHandle = styled.button`
display: flex;
align-items: center;
cursor: grab;
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