Skip to content

Commit

Permalink
Add and edit graph from the UI (#45)
Browse files Browse the repository at this point in the history
* graph from ui

* Add relationships, remove relationships, remove resource, fixes

* colors improvements, fixes, and code improvements
  • Loading branch information
dormeiri authored Mar 18, 2023
1 parent d5dc1e7 commit 05832f3
Show file tree
Hide file tree
Showing 19 changed files with 320 additions and 51 deletions.
1 change: 1 addition & 0 deletions packages/scanner/src/filesIteratorFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class FilesIteratorFactory {
local: FilesIteratorLocal,
config: undefined as never,
scan: undefined as never,
ui: undefined as never,
};

public produce(context: ResourceScanContext): FilesIterator {
Expand Down
1 change: 1 addition & 0 deletions packages/scanner/src/scanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { FilesIteratorSettings, ScanContext } from './types';
import { getDefaultRegex } from './utils';

const DEFAULT_INCLUDE_REGEX = /(.ts|.tsx|.js|.jsx|.java|.py|.go|.tf)$/;
// TODO: Extract to shared component with the UI.
const NOODLE_COMMENT_REGEX = /noodle\s+([<-])(?:-([a-z\s]+)-|-)([->])\s+([a-z0-9-]+)\s*(?:\(([a-z0-9-,]+)+\)|)/;
export const DEFAULT_FILES_WORKERS_NUM = 8;

Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ export interface ResourceGithubOptions {
branch: string;
}

export type Source = 'local' | 'github' | 'config' | 'scan';
export type Source = 'local' | 'github' | 'config' | 'scan' | 'ui';
3 changes: 3 additions & 0 deletions packages/ui/public/img/ui.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 56 additions & 4 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ArrowDownTrayIcon, FolderArrowDownIcon } from '@heroicons/react/20/solid';
import { ArrowDownTrayIcon, FolderArrowDownIcon, PlusIcon } from '@heroicons/react/20/solid';
import type { Resource } from '@noodle-graph/types';
import React, { useEffect, useState } from 'react';

import './App.css';
Expand All @@ -7,27 +8,32 @@ import { Button } from './Button';
import { Details } from './Details';
import { Filter } from './Filter';
import { Pill } from './Pill';
import { ResourceEditModal } from './ResourceEditModal';
import { Select } from './Select';
import { VisNetwork } from './VisNetwork';
import { scanOutputStore } from './scanOutputStore';
import type { ScanResultExtended, SelectOption, FilterOption } from './types';
import { everyIncludes, produceRelationship } from './utils';

export function App() {
const [scanOutput, setScanOutput] = useState<ScanResultExtended>();
const [tags, setTags] = useState<FilterOption<string>[]>([]);
const [resources, setResources] = useState<SelectOption<string>[]>([]);
const [selectedResourceId, setSelectedResourceId] = useState<string>();
const [isResourceEditOpen, setIsResourceEditOpen] = useState<boolean>(false);

const selectedTags = tags.filter((tag) => tag.selected);
const selectedTagValues = selectedTags.map((tag) => tag.value);
const selectedResource = selectedResourceId && scanOutput?.resources.find((r) => r.id === selectedResourceId);
const selectedResource = selectedResourceId ? scanOutput?.resources.find((r) => r.id === selectedResourceId) : undefined;

useEffect(() => {
scanOutputStore.importBundledScanOutput().then(syncWithStore);
}, []);

useEffect(() => {
setSelectedResourceId(undefined);
if (!everyIncludes(selectedTagValues, selectedResource?.tags ?? [])) {
setSelectedResourceId(undefined);
}
}, [tags]);

function syncWithStore() {
Expand All @@ -50,14 +56,51 @@ export function App() {
setTags(newTags);
}

function closeResourceEditModal() {
setIsResourceEditOpen(false);
}

function handleNewResource(resource: Resource): void {
scanOutputStore.addResource(resource);
syncWithStore();
closeResourceEditModal();
}

function handleRemoveResource(resourceId: string): void {
scanOutputStore.removeResource(resourceId);
syncWithStore();
}

function handleAddRelationship(resourceId: string, relationshipResourceId: string): void {
const resource = scanOutputStore.scanOutput.resources.find((r) => r.id === resourceId);
if (!resource) throw new Error('Invalid resource');
resource.relationships ??= [];
resource.relationships.push(produceRelationship({ resourceId: relationshipResourceId }));
syncWithStore();
}

function handleRemoveRelationship(resourceId: string, relationshipResourceId: string): void {
const resource = scanOutputStore.scanOutput.resources.find((r) => r.id === resourceId);
if (!resource) throw new Error('Invalid resource');
resource.relationships = resource.relationships?.filter((r) => r.resourceId !== relationshipResourceId);
syncWithStore();
}

return scanOutput == null ? (
<div>Loading...</div>
) : (
<div className="flex h-screen">
<div className="w-96 bg-darker overflow-auto flex flex-col p-5 gap-5">
<div className="flex justify-between items-center gap-1 mb-7">
<h1 className="text-xl font-black opacity-70">Noodle</h1>
<a href="https://github.com/noodle-graph/monorepo" className="h-5 w-5 opacity-70 hover:opacity-100 transition-opacity" target="_blank">
<img src="img/github.svg" />
</a>
</div>
<div className="flex gap-1">
<Button label="Download" onClick={() => scanOutputStore.download()} icon={FolderArrowDownIcon} />
<Button label="Import" onClick={() => scanOutputStore.import().then(syncWithStore)} icon={ArrowDownTrayIcon} />
<Button label="Add Resource" onClick={() => setIsResourceEditOpen(true)} icon={PlusIcon} />
</div>
<Filter options={tags} onChange={handleTagsSelectionChange} title="Tags" />
<Select options={resources} onChange={setSelectedResourceId} title="Resource" />
Expand All @@ -66,13 +109,22 @@ export function App() {
<Pill key={`pill-${i}`} onClick={() => handleTagPillClick(tag)} label={tag.value} />
))}
</div>
{selectedResource && <Details resource={selectedResource} resourceSelected={setSelectedResourceId} />}
{selectedResource && (
<Details
resource={selectedResource}
resourceSelected={setSelectedResourceId}
removeResource={handleRemoveResource}
addRelationship={handleAddRelationship}
removeRelationship={handleRemoveRelationship}
/>
)}
</div>
<div>
{scanOutput && (
<VisNetwork scanOutput={scanOutput} selectedTags={selectedTagValues} resourceSelected={setSelectedResourceId} selectedResourceId={selectedResourceId} />
)}
</div>
<ResourceEditModal isOpen={isResourceEditOpen} close={closeResourceEditModal} save={handleNewResource} />
</div>
);
}
14 changes: 11 additions & 3 deletions packages/ui/src/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
interface ButtonProps {
onClick: () => void;
label: string;
label?: string;
icon?: React.ForwardRefExoticComponent<React.SVGProps<SVGSVGElement> & { title?: string; titleId?: string }>;
background?: boolean;
danger?: boolean;
}

export function Button(props: ButtonProps) {
return (
<button
onClick={() => props.onClick!()}
className="text-secondary font-extrabold hover:text-primary transition-colors bg-secondary p-2 pr-3 text-xs flex items-center rounded gap-1"
className={`h-9 text-secondary font-extrabold hover:text-primary transition-colors ${
props.background ? 'bg-secondary' : ''
} p-2 text-xs flex items-center rounded gap-1 ${props.danger ? 'hover:bg-danger' : ''}`}
>
{props.icon && <props.icon width={15} />}
<div>{props.label}</div>
{props.label && <div>{props.label}</div>}
</button>
);
}

Button.defaultProps = {
background: true,
};
22 changes: 19 additions & 3 deletions packages/ui/src/Details.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { TrashIcon } from '@heroicons/react/20/solid';
import type { ReactElement } from 'react';
import React from 'react';

import { Button } from './Button';
import { Pill } from './Pill';
import { Select } from './Select';
import { getTypeImagePath, prettifySource } from './constants';
import type { RelationshipExtended, ResourceExtended } from './types';
import { scanOutputStore } from './scanOutputStore';
import type { RelationshipExtended, ResourceExtended, SelectOption } from './types';

interface DetailProps {
resource: ResourceExtended;
resourceSelected: (resourceId: string) => void;
removeResource: (resourceId: string) => void;
addRelationship: (resourceId: string, relationshipResourceId: string) => void;
removeRelationship: (resourceId: string, relationshipResourceId: string) => void;
}

function detail(name: string, content?: ReactElement | string | boolean): JSX.Element | false {
Expand All @@ -24,6 +31,12 @@ function detail(name: string, content?: ReactElement | string | boolean): JSX.El
export function Details(props: DetailProps) {
const prettySource = prettifySource(props.resource.source);

const resourceIdOptions: SelectOption<string>[] = scanOutputStore.scanOutput.resources.map((resource) => ({
key: resource.id,
value: resource.id,
display: resource.name ?? resource.id,
}));

function link(text: string, url?: string, iconImgSrc?: string): JSX.Element {
const content = (
<>
Expand All @@ -47,14 +60,15 @@ export function Details(props: DetailProps) {
function relationship(relationship: RelationshipExtended) {
return (
<div className="flex flex-col bg-primary text-secondary p-4 rounded">
<div>
<div className="flex justify-between">
<div
className="inline-block text-sm cursor-pointer hover:bg-opacity-50 p-2 rounded bg-secondary transition-colors"
className="inline-block text-sm cursor-pointer hover:bg-opacity-50 p-2 rounded bg-secondary transition-colors hover:text-primary"
onClick={() => props.resourceSelected(relationship.resourceId)}
>
{relationship.resource?.type && <img src={getTypeImagePath(relationship.resource.type)} className="max-h-5 inline-block mr-2" />}
<span className="bg-inherit">{relationship.resource?.name ?? relationship.resourceId}</span>
</div>
<Button icon={TrashIcon} onClick={() => props.removeRelationship(props.resource.id, relationship.resourceId)} danger={true} />
</div>
{!!relationship.tags?.length && (
<div className="flex flex-wrap gap-2 text-xs mt-2">
Expand All @@ -75,6 +89,7 @@ export function Details(props: DetailProps) {
{props.resource.type && <img src={getTypeImagePath(props.resource.type)} className="max-h-7" />}
<div className="text-xl font-bold">{props.resource.name}</div>
<div className="h-0.5 bg-secondary flex-1"></div>
<Button icon={TrashIcon} onClick={() => props.removeResource(props.resource.id)} danger={true} />
</div>
{props.resource.description && <div className="text-secondary text-sm font-bold">{props.resource.description}</div>}
{detail('ID', props.resource.id)}
Expand All @@ -90,6 +105,7 @@ export function Details(props: DetailProps) {
</div>
)
)}
<Select title="Add relationship" options={resourceIdOptions} onChange={(resourceId) => props.addRelationship(props.resource.id, resourceId)} />
{detail(
'Tags',
!!props.resource.tags?.length && (
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/Filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ export function Filter<T>(props: FilterProps<T>) {
<Transition as={Fragment} leave="transition ease-in duration-100" leaveFrom="opacity-100" leaveTo="opacity-0" afterLeave={() => setQuery('')}>
<Combobox.Options className="absolute max-h-60 w-full overflow-auto bg-secondary p-1 block z-50">
{filteredOptions.map((option) => (
<Combobox.Option key={option.key} value={option.value} className="p-1 flex items-center text-secondary cursor-pointer">
<Combobox.Option
key={option.key}
value={option.value}
className="p-1 flex items-center text-secondary hover:text-primary transition-colors cursor-pointer"
>
{
<>
{option.selected ? <CheckIcon className="w-5 h-5 mr-1" /> : <div className="w-5 h-5 mr-1"></div>}
Expand Down
38 changes: 38 additions & 0 deletions packages/ui/src/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useRef } from 'react';

interface InputProps {
id: string;
placeholder?: string;
label: string;
type?: string;
onChange?: (value: string) => void;
value?: string;
}

export function Input(props: InputProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null);

return (
<div
className="bg-secondary p-1 pl-2 border-0 outline-none rounded text-disabled hover:text-secondary focus-within:text-secondary transition-colors"
onClick={() => inputRef.current?.focus()}
>
<label htmlFor={props.id} className="mr-2 font-bold text-xs">
{props.label}
</label>
<input
ref={inputRef}
value={props.value ?? ''}
type={props.type}
className="bg-primary outline-none p-1 pl-2 rounded text-primary font-mono"
id={props.id}
placeholder={props.placeholder}
onChange={(e) => props.onChange?.(e.target.value)}
/>
</div>
);
}

Input.defaultProps = {
type: 'text',
};
26 changes: 26 additions & 0 deletions packages/ui/src/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Dialog } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/20/solid';

import { Button } from './Button';

interface ModalProps {
isOpen: boolean;
close: () => void;
title: string;
children: React.ReactNode;
}

export function Modal(props: ModalProps) {
return (
<Dialog open={props.isOpen} onClose={() => props.close()} className="z-50 fixed inset-0 flex items-center justify-center">
<div className="fixed inset-0 bg-darker opacity-50" />
<Dialog.Panel className="z-50 p-5 bg-darker w-96 rounded-xl">
<Dialog.Title className="text-xl font-black mb-5 flex justify-between items-start">
{props.title}
<Button onClick={() => props.close()} icon={XMarkIcon} background={false} />
</Dialog.Title>
{props.children}
</Dialog.Panel>
</Dialog>
);
}
Loading

0 comments on commit 05832f3

Please sign in to comment.