Skip to content

Commit

Permalink
Merge pull request #364 from Pixilib/metadata-tags
Browse files Browse the repository at this point in the history
Metadata tags
  • Loading branch information
salimkanoun authored Dec 9, 2024
2 parents e9555ac + 3f6801a commit 72037fa
Show file tree
Hide file tree
Showing 33 changed files with 966 additions and 87 deletions.
3 changes: 2 additions & 1 deletion src/content/series/SeriesRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import PreviewSeries from './PreviewSeries';
import { useConfirm } from '../../services/ConfirmContextProvider';
import { useCustomToast } from '../../utils/toastify';
import { Modal, Spinner } from '../../ui';
import Tags from './Tags';
import Tags from './TagsTree';
import { exportRessource, exportSeriesToNifti } from '../../services/export';


interface SeriesRootProps {
studyId: string;
}
Expand Down
85 changes: 0 additions & 85 deletions src/content/series/Tags.tsx

This file was deleted.

126 changes: 126 additions & 0 deletions src/content/series/TagsTree.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useMemo, useState } from "react";
import { Badge, Input, Spinner } from "../../ui";
import { instanceHeader, instanceTags } from "../../services/instances";
import { getInstancesOfSeries } from "../../services/orthanc";
import { Metadata, Tag as TagType } from "../../utils/types";
import { useCustomQuery } from "../../utils";
import TagSequence from "./metadata/TagSequence";
import TagItem from "./metadata/TagItem";

type TagsProps = {
seriesId: string;
};

const TagsTree = ({ seriesId }: TagsProps) => {
const [instanceNumber, setInstanceNumber] = useState<number>(1);
const [searchTerm, setSearchTerm] = useState<string>("");

const { data: instances } = useCustomQuery(
["series", seriesId, "instances"],
() => getInstancesOfSeries(seriesId)
);

const currentInstanceId = useMemo(() => {
return instanceNumber != null && instances != null
? instances[instanceNumber - 1]?.id
: null;
}, [instanceNumber, instances]);

const { data: header } = useCustomQuery<Metadata>(
["instances", currentInstanceId, "metadata"],
() => instanceHeader(currentInstanceId),
{
enabled: currentInstanceId !== null,
}
);

const { data: tags } = useCustomQuery<Metadata>(
["instances", currentInstanceId, "tags"],
() => instanceTags(currentInstanceId),
{
enabled: currentInstanceId !== null,
}
);

const metadata = useMemo(() => {
if (!header || !tags) return {};
return {
...header,
...tags,
};
}, [header, tags]);

const filteredMetadata = useMemo(() => {
if (!searchTerm) return metadata;
const lowerSearchTerm = searchTerm.toLowerCase();

return Object.entries(metadata).reduce((filtered, [tagAddress, tag]) => {
const tagValue = String(tag.Value).toLowerCase();
const tagName = tag.Name?.toLowerCase();

if (
tagAddress.toLowerCase().includes(lowerSearchTerm) ||
tagName?.includes(lowerSearchTerm) ||
tagValue.includes(lowerSearchTerm)
) {
filtered[tagAddress] = tag;
}

return filtered;
}, {} as Metadata);
}, [metadata, searchTerm]);

const getComponent = (tagAddress: string, tag: TagType) => {
if (Array.isArray(tag.Value)) {
return (
<li key={tagAddress} className="ml-4 list-none ">
<TagSequence tag={tag}>
{tag.Value.map((metadata, index) => (
<li key={`${tagAddress}-${index}`} >
<ul className="list-disc">
{Object.entries(metadata).map(([addressTag, tag]) =>
getComponent(addressTag, tag)
)}
</ul>
</li>
))}
</TagSequence>
</li>
);
} else {
return (
<li className="ml-4 list-none odd:bg-white even:bg-light-gray" key={tagAddress}>
<TagItem address={tagAddress} tag={tag} />
</li>
);
}
};

return (
<div className="space-y-3">
<Input
label={"Instance Number " + (instanceNumber ?? 1)}
type="range"
min={1}
max={instances?.length ?? 0}
value={instanceNumber ?? 1}
onChange={(event) => setInstanceNumber(Number(event.target?.value))}
/>
<Input
label="Search Metadata"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target?.value)}
placeholder="Search by tag address, name, or value"
/>
<div className="overflow-auto min-h-[800px] max-h-[800px] p-3 pl-0">
<ul className="list-disc">
{Object.entries(filteredMetadata)
.sort()
.map(([tagAddress, tag]) => getComponent(tagAddress, tag))}
</ul>
</div>
</div>
);
};

export default TagsTree;
17 changes: 17 additions & 0 deletions src/content/series/metadata/TagItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Tag as TagType } from "../../../utils/types";

type TagProps = {
address: string;
tag: TagType;
children?: React.ReactNode;
};
const TagItem = ({ address, tag }: TagProps) => {
return (
<div className="w-full flex justify-between">
<span>{address} - {tag.Name}</span>
<span>{tag.Value as string}</span>
</div>
);
};

export default TagItem;
26 changes: 26 additions & 0 deletions src/content/series/metadata/TagSequence.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React, { useState } from "react";
import { Tag as TagType } from "../../../utils/types";
import { Button } from "../../../ui";
import { Colors } from "../../../utils";

type TagProps = {
tag: TagType;
children?: React.ReactNode;
};
const TagSequence = ({ tag, children }: TagProps) => {
const [isExpanded, setIsExpanded] = useState(false);

return (
<ul className="list-none ml-4 m-3 space-y-3">
<Button
color={Colors.primary}
onClick={() => setIsExpanded((expanded) => !expanded)}
>
{tag.Name + (isExpanded ? "▲" : "▼")}
</Button>
{isExpanded && children}
</ul>
);
};

export default TagSequence;
53 changes: 53 additions & 0 deletions src/stories/Button.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';

import { Button } from './Button';

// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: 'Example/Button',
component: Button,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: 'centered',
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ['autodocs'],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
backgroundColor: { control: 'color' },
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onClick: fn() },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Primary: Story = {
args: {
primary: true,
label: 'Button',
},
};

export const Secondary: Story = {
args: {
label: 'Button',
},
};

export const Large: Story = {
args: {
size: 'large',
label: 'Button',
},
};

export const Small: Story = {
args: {
size: 'small',
label: 'Button',
},
};
37 changes: 37 additions & 0 deletions src/stories/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import React from 'react';

import './button.css';

export interface ButtonProps {
/** Is this the principal call to action on the page? */
primary?: boolean;
/** What background color to use */
backgroundColor?: string;
/** How large should the button be? */
size?: 'small' | 'medium' | 'large';
/** Button contents */
label: string;
/** Optional click handler */
onClick?: () => void;
}

/** Primary UI component for user interaction */
export const Button = ({
primary = false,
size = 'medium',
backgroundColor,
label,
...props
}: ButtonProps) => {
const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
return (
<button
type="button"
className={['storybook-button', `storybook-button--${size}`, mode].join(' ')}
style={{ backgroundColor }}
{...props}
>
{label}
</button>
);
};
Loading

0 comments on commit 72037fa

Please sign in to comment.