Skip to content

Commit

Permalink
feat: reorder steps on workflow card (#1118)
Browse files Browse the repository at this point in the history
* feat: reorder steps on workflow card

* refactor: duplicated style on StepCard

* fix: too small hitbox on StepCard button
  • Loading branch information
AndrasEszes authored Aug 8, 2024
1 parent 4ab6ba7 commit a76e82c
Show file tree
Hide file tree
Showing 7 changed files with 265 additions and 87 deletions.
137 changes: 100 additions & 37 deletions source/javascripts/components/StepCard/StepCard.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,38 @@
import { Avatar, Box, Card, Skeleton, SkeletonBox, Text } from '@bitrise/bitkit';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import useStep from '@/hooks/useStep';
import { getSortableStepId } from '../WorkflowCard/WorkflowCard.utils';

type StepCardProps = {
workflowId: string;
stepIndex: number;
onClick?: VoidFunction;
isDraggable?: boolean;
showSecondary?: boolean;
onClick?: VoidFunction;
};

const DragHandle = () => {
return (
<svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<circle cx="2" cy="2" r="1" />
<circle cx="6" cy="2" r="1" />
<circle cx="2" cy="6" r="1" />
<circle cx="6" cy="6" r="1" />
<circle cx="2" cy="10" r="1" />
<circle cx="6" cy="10" r="1" />
</svg>
);
};

const StepCard = ({ workflowId, stepIndex, showSecondary = true, onClick }: StepCardProps) => {
const StepCard = ({ workflowId, stepIndex, isDraggable, showSecondary = true, onClick }: StepCardProps) => {
const isButton = Boolean(onClick);
const sortableStepId = getSortableStepId(workflowId, stepIndex);

const step = useStep(workflowId, stepIndex);
const sortable = useSortable({ id: sortableStepId, disabled: !isDraggable });

const isActive = sortable.active?.id === sortableStepId;

if (!step) {
return null;
Expand All @@ -19,9 +42,22 @@ const StepCard = ({ workflowId, stepIndex, showSecondary = true, onClick }: Step

if (isLoading) {
return (
<Card variant="outline" p="8" borderRadius="4">
<Skeleton isActive display="flex" gap="4">
<SkeletonBox height="32" width="32" />
<Card p="8" display="flex" variant="outline" borderRadius="4" alignItems="center" pl={isDraggable ? 0 : 8}>
{isDraggable && (
<Box
w="24"
display="flex"
alignItems="center"
borderLeftRadius="4"
color="text/disabled"
justifyContent="center"
>
<DragHandle />
</Box>
)}

<Skeleton display="flex" alignItems="center" gap="8" isActive>
<SkeletonBox height="32" width="32" borderRadius="4" />
<Box display="flex" flexDir="column" gap="4">
<SkeletonBox height="14" width="250px" />
{showSecondary && <SkeletonBox height="14" width="100px" />}
Expand All @@ -31,40 +67,67 @@ const StepCard = ({ workflowId, stepIndex, showSecondary = true, onClick }: Step
);
}

const content = (
<>
<Avatar
size="32"
src={icon}
variant="step"
outline="1px solid"
name={title || cvs}
outlineColor="border/minimal"
/>
<Box minW={0} textAlign="left">
<Text textStyle="body/sm/regular" hasEllipsis>
{title}
</Text>
{showSecondary && (
<Text textStyle="body/sm/regular" color="text/secondary" hasEllipsis>
{selectedVersion || 'Always latest'}
return (
<Card
display="flex"
variant="outline"
borderRadius="4"
ref={sortable.setNodeRef}
_hover={isButton ? { borderColor: 'border/hover', boxShadow: 'small' } : undefined}
{...(isActive ? { zIndex: 999, borderColor: 'border/hover', boxShadow: 'small' } : {})}
style={{ transition: sortable.transition, transform: CSS.Transform.toString(sortable.transform) }}
>
{isDraggable && (
<Box
as="button"
minW={24}
maxW={24}
cursor="grab"
display="flex"
alignSelf="stretch"
alignItems="center"
borderLeftRadius="4"
justifyContent="center"
{...sortable.listeners}
ref={sortable.setActivatorNodeRef}
_hover={{ backgroundColor: 'background/hover', color: 'icon/secondary' }}
{...(isActive
? { backgroundColor: 'background/hover', color: 'icon/secondary' }
: { color: 'icon/tertiary' })}
>
<DragHandle />
</Box>
)}

<Box
display="flex"
p="8"
gap="8"
minW={0}
flex="1"
as={isButton ? 'button' : 'div'}
pl={isDraggable ? 0 : 8}
onClick={onClick}
>
<Avatar
size="32"
src={icon}
variant="step"
outline="1px solid"
name={title || cvs}
outlineColor="border/minimal"
/>
<Box minW={0} textAlign="left">
<Text textStyle="body/sm/regular" hasEllipsis>
{title}
</Text>
)}
{showSecondary && (
<Text textStyle="body/sm/regular" color="text/secondary" hasEllipsis>
{selectedVersion || 'Always latest'}
</Text>
)}
</Box>
</Box>
</>
);

if (onClick) {
return (
<Card variant="outline" display="flex" gap="8" p="8" borderRadius="4" as="button" onClick={onClick} withHover>
{content}
</Card>
);
}

return (
<Card variant="outline" display="flex" gap="8" p="8" borderRadius="4">
{content}
</Card>
);
};
Expand Down
94 changes: 58 additions & 36 deletions source/javascripts/components/WorkflowCard/WorkflowCard.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { Fragment } from 'react';
import { Box, ButtonGroup, Card, CardProps, Collapse, ControlButton, Icon, Text, useDisclosure } from '@bitrise/bitkit';
import { useShallow } from 'zustand/react/shallow';
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers';
import StepCard from '@/components/StepCard/StepCard';
import { Step } from '@/models/Step';
import { ChainedWorkflowPlacement as Placement } from '@/models/Workflow';
import useBitriseYmlStore from '@/hooks/useBitriseYmlStore';
import useWorkflowUsedBy from '@/pages/WorkflowsPage/hooks/useWorkflowUsedBy';
import useWorkflow from './hooks/useWorkflow';
import AddStepButton from './components/AddStepButton';
import { getSortableStepId, getUsedByText, parseSortableStepId } from './WorkflowCard.utils';

type StepEditCallback = (workflowId: string, stepIndex: number) => void;
type WorkflowEditCallback = (workflowId: string) => void;
type MoveStepCallback = (workflowId: string, stepIndex: number, to: number) => void;

type WorkflowCardProps = CardProps & {
workflowId: string;
Expand All @@ -21,22 +26,12 @@ type WorkflowCardProps = CardProps & {
isExpanded?: boolean;
isEditable?: boolean;
onAddStep?: StepEditCallback;
onMoveStep?: MoveStepCallback;
onSelectStep?: StepEditCallback;
onEditWorkflow?: WorkflowEditCallback;
onChainWorkflow?: WorkflowEditCallback;
};

const getUsedByText = (usedBy: string[]) => {
switch (usedBy.length) {
case 0:
return 'not used by other Workflow';
case 1:
return 'used by 1 Workflow';
default:
return `used by ${usedBy.length} Workflows`;
}
};

const WorkflowCard = ({
workflowId,
parentWorkflowId,
Expand All @@ -46,28 +41,40 @@ const WorkflowCard = ({
isExpanded,
isEditable,
onAddStep,
onMoveStep,
onSelectStep,
onEditWorkflow,
onChainWorkflow,
...props
}: WorkflowCardProps) => {
const workflow = useWorkflow(workflowId);
const sensors = useSensors(useSensor(PointerSensor));
const workflowUsedBy = useWorkflowUsedBy(workflowId);
const { isOpen, onToggle } = useDisclosure({ defaultIsOpen: isExpanded || isFixed });
const deleteChainedWorkflow = useBitriseYmlStore(useShallow((s) => s.deleteChainedWorkflow));

const sortableItemIdentifiers = (workflow?.steps ?? []).map((_, stepIndex) => {
return getSortableStepId(workflowId, stepIndex);
});

const isRoot = !parentWorkflowId;
const hasNoSteps = !workflow?.steps?.length;
const hasAfterRunWorkflows = !!workflow?.after_run?.length;
const hasBeforeRunWorkflows = !!workflow?.before_run?.length;

const handleMoveStep = (e: DragEndEvent) => {
const { stepIndex } = parseSortableStepId(e.active.id.toString());
const { stepIndex: to } = parseSortableStepId(e.over?.id.toString() || '');
onMoveStep?.(workflowId, stepIndex, to);
};

if (!workflow) {
// TODO: Missing mpty state
// eslint-disable-next-line no-console
console.warn(`Workflow '${workflowId}' is not found in yml!`);
return null;
}

const isRoot = !parentWorkflowId;
const hasNoSteps = !workflow.steps?.length;
const hasAfterRunWorkflows = Boolean(workflow.after_run?.length);
const hasBeforeRunWorkflows = Boolean(workflow.before_run?.length);

return (
<Card variant={isRoot ? 'elevated' : 'outline'} {...props}>
<Box display="flex" alignItems="center" px="8" py="6" className="group">
Expand Down Expand Up @@ -141,6 +148,7 @@ const WorkflowCard = ({
placement="before_run"
isEditable={isEditable}
onAddStep={onAddStep}
onMoveStep={onMoveStep}
onSelectStep={onSelectStep}
onEditWorkflow={onEditWorkflow}
onChainWorkflow={onChainWorkflow}
Expand All @@ -158,26 +166,39 @@ const WorkflowCard = ({
</Card>
)}

{workflow.steps?.map((s, stepIndex, steps) => {
const isLastStep = stepIndex === steps.length - 1;
const [cvs = ''] = Object.entries(s)[0] as [string, Step];

return (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={`workflows[${workflowId}].steps[${stepIndex}][${cvs}]`}>
{isEditable && <AddStepButton onClick={() => onAddStep?.(workflowId, stepIndex)} my={-8} />}
<StepCard
workflowId={workflowId}
stepIndex={stepIndex}
onClick={onSelectStep && (() => onSelectStep(workflowId, stepIndex))}
showSecondary
/>
{isEditable && isLastStep && (
<AddStepButton onClick={() => onAddStep?.(workflowId, stepIndex + 1)} my={-8} />
)}
</Fragment>
);
})}
<Box display="flex" flexDir="column" gap="8">
<DndContext
sensors={sensors}
onDragEnd={handleMoveStep}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext items={sortableItemIdentifiers} strategy={verticalListSortingStrategy}>
{workflow?.steps?.map((s, stepIndex, steps) => {
const isLastStep = stepIndex === steps.length - 1;
const [cvs = ''] = Object.entries(s)[0] as [string, Step];

return (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={`workflows[${workflowId}].steps[${stepIndex}][${cvs}]`}>
{isEditable && <AddStepButton onClick={() => onAddStep?.(workflowId, stepIndex)} my={-8} />}

<StepCard
showSecondary
stepIndex={stepIndex}
workflowId={workflowId}
isDraggable={isEditable && !!onMoveStep}
onClick={onSelectStep && (() => onSelectStep(workflowId, stepIndex))}
/>

{isEditable && isLastStep && (
<AddStepButton onClick={() => onAddStep?.(workflowId, stepIndex + 1)} my={-8} />
)}
</Fragment>
);
})}
</SortableContext>
</DndContext>
</Box>

{hasAfterRunWorkflows && <Icon name="ArrowDown" size="16" color="icon/tertiary" alignSelf="center" />}

Expand All @@ -192,6 +213,7 @@ const WorkflowCard = ({
placement="after_run"
isEditable={isEditable}
onAddStep={onAddStep}
onMoveStep={onMoveStep}
onSelectStep={onSelectStep}
onEditWorkflow={onEditWorkflow}
onChainWorkflow={onChainWorkflow}
Expand Down
19 changes: 19 additions & 0 deletions source/javascripts/components/WorkflowCard/WorkflowCard.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export function getUsedByText(usedBy: string[]) {
switch (usedBy.length) {
case 0:
return 'not used by other Workflow';
case 1:
return 'used by 1 Workflow';
default:
return `used by ${usedBy.length} Workflows`;
}
}

export function getSortableStepId(workflowId: string, stepIndex: number) {
return `${workflowId}->${stepIndex}`;
}

export function parseSortableStepId(sortableStepId: string) {
const [workflowId, stepIndexAsString] = sortableStepId.split('->');
return { workflowId, stepIndex: Number(stepIndexAsString) };
}
8 changes: 8 additions & 0 deletions source/javascripts/contexts/BitriseYmlProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type BitriseYmlProviderState = {
defaultMeta?: Meta;

// Workflow related actions
moveStep: (workflowId: string, stepIndex: number, to: number) => void;
createWorkflow: (workflowId: string, baseWorkflowId?: string) => void;
deleteWorkflow: (workflowId: string) => void;
deleteChainedWorkflow: (
Expand All @@ -35,6 +36,13 @@ const createBitriseYmlStore = (yml: BitriseYml, defaultMeta?: Meta) => {
return createStore<BitriseYmlProviderState>()((set) => ({
yml,
defaultMeta,
moveStep(workflowId, stepIndex, to) {
return set((state) => {
return {
yml: BitriseYmlService.moveStep(workflowId, stepIndex, to, state.yml),
};
});
},
createWorkflow(workflowId, baseWorkflowId) {
return set((state) => {
return {
Expand Down
Loading

0 comments on commit a76e82c

Please sign in to comment.