[ENG-1276] move assigned tags to top of tag assign list (#1768)

* move assigned tags to top of tag assign list

* sort tags by most recently assigned

* console.log

* error boundary
This commit is contained in:
Brendan Allan
2023-11-14 20:35:50 +11:00
committed by GitHub
parent 4c82a45b15
commit 428edb8d99
7 changed files with 148 additions and 68 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tag_on_object" ADD COLUMN "date_created" DATETIME;

View File

@@ -353,6 +353,8 @@ model TagOnObject {
object_id Int
object Object @relation(fields: [object_id], references: [id], onDelete: Restrict)
date_created DateTime?
@@id([tag_id, object_id])
@@map("tag_on_object")
}

View File

@@ -55,6 +55,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
.select(tag::select!({
id
tag_objects(vec![tag_on_object::object_id::in_vec(object_ids.clone())]): select {
date_created
object: select {
id
}
@@ -65,15 +66,7 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
Ok(tags_with_objects
.into_iter()
.map(|tag| {
(
tag.id,
tag.tag_objects
.into_iter()
.map(|rel| rel.object.id)
.collect::<Vec<_>>(),
)
})
.map(|tag| (tag.id, tag.tag_objects))
.collect::<BTreeMap<_, _>>())
},
)
@@ -249,7 +242,9 @@ pub(crate) fn mount() -> AlphaRouter<Ctx> {
db_creates.push(tag_on_object::CreateUnchecked {
tag_id: args.tag_id,
object_id: id,
_params: vec![],
_params: vec![tag_on_object::date_created::set(Some(
Utc::now().into(),
))],
});
sync_ops.extend(sync.relation_create(sync_id!(pub_id), []));

View File

@@ -1,9 +1,11 @@
import { Plus } from '@phosphor-icons/react';
import { useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import clsx from 'clsx';
import { useRef } from 'react';
import { forwardRef, MutableRefObject, RefObject, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ExplorerItem, useLibraryQuery } from '@sd/client';
import { dialogManager, ModifierKeys } from '@sd/ui';
import { Button, dialogManager, ModifierKeys, tw } from '@sd/ui';
import CreateDialog, {
AssignTagItems,
useAssignItemsToTag
@@ -13,33 +15,40 @@ import { useOperatingSystem } from '~/hooks';
import { useScrolled } from '~/hooks/useScrolled';
import { keybindForOs } from '~/util/keybinds';
export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | 'Path' }>> }) => {
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const EmptyContainer = tw.div`py-1 text-center text-xs text-ink-faint`;
interface Props {
items: Array<Extract<ExplorerItem, { type: 'Object' | 'Path' }>>;
}
function useData({ items }: Props) {
const tags = useLibraryQuery(['tags.list'], { suspense: true });
// Map<tag::id, Vec<object::id>>
const tagsWithObjects = useLibraryQuery([
'tags.getWithObjects',
props.items
.map((item) => {
if (item.type === 'Path') return item.item.object?.id;
else if (item.type === 'Object') return item.item.id;
})
.filter((item): item is number => item !== undefined)
]);
const tagsWithObjects = useLibraryQuery(
[
'tags.getWithObjects',
items
.map((item) => {
if (item.type === 'Path') return item.item.object?.id;
else if (item.type === 'Object') return item.item.id;
})
.filter((item): item is number => item !== undefined)
],
{ suspense: true }
);
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: tags.data?.length || 0,
getScrollElement: () => parentRef.current,
estimateSize: () => 30,
paddingStart: 2
});
return { tags, tagsWithObjects };
}
const { isScrolled } = useScrolled(parentRef, 10);
export default (props: Props) => {
const ref = useRef<HTMLDivElement>(null);
const { isScrolled } = useScrolled(ref, 10);
const assignItemsToTag = useAssignItemsToTag();
const os = useOperatingSystem();
const keybind = keybindForOs(os);
const queryClient = useQueryClient();
return (
<>
@@ -54,7 +63,85 @@ export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | '
}}
/>
<Menu.Separator className={clsx('mx-0 mb-0 transition', isScrolled && 'shadow')} />
{tags.data && tags.data.length > 0 ? (
<ErrorBoundary
onReset={() => queryClient.invalidateQueries()}
fallbackRender={(props) => (
<EmptyContainer>
Failed to load tags
<Button onClick={() => props.resetErrorBoundary()}>Retry</Button>
</EmptyContainer>
)}
>
<Tags parentRef={ref} {...props} />
</ErrorBoundary>
</>
);
};
const Tags = ({ items, parentRef }: Props & { parentRef: RefObject<HTMLDivElement> }) => {
const { tags, tagsWithObjects } = useData({ items });
// tags are sorted by assignment, and assigned tags are sorted by most recently assigned
const sortedTags = useMemo(() => {
if (!tags.data) return [];
const assigned = [];
const unassigned = [];
for (const tag of tags.data) {
if (tagsWithObjects.data?.[tag.id] === undefined) unassigned.push(tag);
else assigned.push(tag);
}
if (tagsWithObjects.data) {
assigned.sort((a, b) => {
const aObjs = tagsWithObjects.data[a.id],
bObjs = tagsWithObjects.data[b.id];
function getMaxDate(data: typeof aObjs) {
if (!data) return null;
let max = null;
for (const { date_created } of data) {
if (!date_created) continue;
const date = new Date(date_created);
if (!max) max = date;
else if (date > max) max = date;
}
return max;
}
const aMaxDate = getMaxDate(aObjs),
bMaxDate = getMaxDate(bObjs);
if (!aMaxDate || !bMaxDate) {
if (aMaxDate && !bMaxDate) return 1;
else if (!aMaxDate && bMaxDate) return -1;
else return 0;
} else {
return Number(bMaxDate) - Number(aMaxDate);
}
});
}
return [...assigned, ...unassigned];
}, [tags.data, tagsWithObjects.data]);
const rowVirtualizer = useVirtualizer({
count: sortedTags.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 30,
paddingStart: 2
});
const assignItemsToTag = useAssignItemsToTag();
return (
<>
{sortedTags.length > 0 ? (
<div
ref={parentRef}
className="h-full w-full overflow-auto"
@@ -65,14 +152,16 @@ export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | '
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const tag = tags.data[virtualRow.index];
const tag = sortedTags[virtualRow.index];
if (!tag) return null;
const objectsWithTag = new Set(tagsWithObjects.data?.[tag?.id]);
const objectsWithTag = new Set(
tagsWithObjects.data?.[tag?.id]?.map((d) => d.object.id)
);
// only unassign if all objects have tag
// this is the same functionality as finder
const unassign = props.items.every((item) => {
const unassign = items.every((item) => {
if (item.type === 'Object') {
return objectsWithTag.has(item.item.id);
} else {
@@ -100,7 +189,7 @@ export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | '
tag.id,
unassign
? // use objects that already have tag
props.items.flatMap((item) => {
items.flatMap((item) => {
if (
item.type === 'Object' ||
item.type === 'Path'
@@ -111,22 +200,16 @@ export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | '
return [];
})
: // use objects that don't have tag
props.items.flatMap<AssignTagItems[number]>(
(item) => {
if (item.type === 'Object') {
if (
!objectsWithTag.has(
item.item.id
)
)
return [item];
} else if (item.type === 'Path') {
items.flatMap<AssignTagItems[number]>((item) => {
if (item.type === 'Object') {
if (!objectsWithTag.has(item.item.id))
return [item];
}
return [];
} else if (item.type === 'Path') {
return [item];
}
),
return [];
}),
unassign
);
@@ -152,9 +235,7 @@ export default (props: { items: Array<Extract<ExplorerItem, { type: 'Object' | '
</div>
</div>
) : (
<div className="py-1 text-center text-xs text-ink-faint">
{tags.data ? 'No tags' : 'Failed to load tags'}
</div>
<EmptyContainer>No tags</EmptyContainer>
)}
</>
);

View File

@@ -17,8 +17,6 @@ export const Component = () => {
const doRestore = useBridgeMutation('backups.restore');
const doDelete = useBridgeMutation('backups.delete');
console.log(doRestore.isLoading);
return (
<>
<Heading

View File

@@ -39,7 +39,7 @@ export type Procedures = {
{ key: "sync.messages", input: LibraryArgs<null>, result: CRDTOperation[] } |
{ key: "tags.get", input: LibraryArgs<number>, result: Tag | null } |
{ key: "tags.getForObject", input: LibraryArgs<number>, result: Tag[] } |
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key: number]: number[] } } |
{ key: "tags.getWithObjects", input: LibraryArgs<number[]>, result: { [key: number]: { date_created: string | null; object: { id: number } }[] } } |
{ key: "tags.list", input: LibraryArgs<null>, result: Tag[] } |
{ key: "volumes.list", input: never, result: Volume[] },
mutations:

View File

@@ -69,16 +69,18 @@ const Root = (props: PropsWithChildren<DropdownMenuProps>) => {
{trigger}
</RadixDM.Trigger>
<RadixDM.Portal>
<RadixDM.Content
className={clsx(contextMenuClassNames, width && '!min-w-0', className)}
align="start"
style={{ width }}
{...contentProps}
>
<DropdownMenuContext.Provider value={true}>
{children}
</DropdownMenuContext.Provider>
</RadixDM.Content>
<Suspense fallback={null}>
<RadixDM.Content
className={clsx(contextMenuClassNames, width && '!min-w-0', className)}
align="start"
style={{ width }}
{...contentProps}
>
<DropdownMenuContext.Provider value={true}>
{children}
</DropdownMenuContext.Provider>
</RadixDM.Content>
</Suspense>
</RadixDM.Portal>
</RadixDM.Root>
);