Skip to content

[PLA-2325] add token groups UI#205

Merged
zlayine merged 2 commits intomasterfrom
feature/PLA-2325/token-groups
Mar 4, 2026
Merged

[PLA-2325] add token groups UI#205
zlayine merged 2 commits intomasterfrom
feature/PLA-2325/token-groups

Conversation

@zlayine
Copy link
Copy Markdown
Contributor

@zlayine zlayine commented Feb 28, 2026

No description provided.

@zlayine zlayine requested a review from pawell67 February 28, 2026 11:00
@zlayine zlayine self-assigned this Feb 28, 2026
@github-actions
Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Memory Leak

The page registers an IntersectionObserver and a global snackbar events.on('transaction', ...) listener but does not appear to unregister/disconnect them on unmount. This can lead to duplicate pagination calls and repeated event handling when navigating away and back to the page.

const loadMoreWithObserver = () => {
    const observer = new IntersectionObserver(
        async (entries) => {
            if (entries[0].isIntersecting) {
                try {
                    if (!tokenGroups.value.cursor || isPaginationLoading.value) return;
                    isPaginationLoading.value = true;
                    const collectionId = searchCollectionInput.value || null;
                    const res = await TokenGroupApi.getTokenGroups(collectionId, tokenGroups.value.cursor);
                    const data = DTOFactory.forTokenGroups(res);
                    tokenGroups.value = {
                        items: [...tokenGroups.value.items, ...data.items],
                        cursor: data.cursor,
                    };
                    isPaginationLoading.value = false;
                } catch {
                    isPaginationLoading.value = false;
                }
            }
        },
        { root: null, rootMargin: '0px', threshold: 1.0 }
    );
    observer.observe(paginatorRef.value);
};

const openModalSlide = (componentName: string, group: any) => {
    slideComponent.value = { componentName, componentPath: 'token-group', ...group };
    modalSlide.value = true;
};

const closeModalSlide = () => {
    modalSlide.value = false;
    setTimeout(() => {
        slideComponent.value = null;
    }, 500);
};

const openTransactionSlide = async (transactionId: string) => {
    if (modalSlide.value) closeModalSlide();
    setTimeout(() => {
        openModalSlide('DetailsTransactionSlideover', { id: transactionId, state: TransactionState.PENDING });
    }, 600);
};

onMounted(async () => {
    getTokenGroups();
    loadMoreWithObserver();
    events.on('transaction', openTransactionSlide);
});
Validation Bug

The tokenId field is modeled as an object (TokenIdType) via TokenIdInput, but the validation schema treats it as a string and also marks it as not required. This mismatch can allow invalid submissions or fail validation unexpectedly, and may send a malformed EncodableTokenIdInput to the API.

const tokenId: Ref<TokenIdType> = ref({ tokenId: '', tokenType: TokenIdSelectType.Integer });
const idempotencyKey = ref('');
const skipValidation = ref(false);
const formRef = ref();
const signingAccount = ref('');

const validation = yup.object({
    tokenGroupId: yup.number().typeError('Token Group ID must be a number').required(),
    collectionId: collectionIdRequiredSchema,
    tokenId: stringNotRequiredSchema,
    idempotencyKey: stringNotRequiredSchema,
    skipValidation: booleanNotRequiredSchema,
});
Data Typing

Collection/token group IDs are handled as strings while the GraphQL schema expects BigInt. Ensure numeric coercion/normalization for collectionId (and similar IDs) before sending requests, and prefer stable keys (e.g., group id) over array indices in v-for to avoid rendering glitches when lists change.

        <FormInput
            v-model="searchCollectionInput"
            name="searchInput"
            label="Collection ID"
            type="number"
            placeholder="Filter by collection ID"
            @input-change="searchChange"
        />
    </div>
    <div class="flex gap-2 sm:px-6 lg:px-8 py-2 mb-2 items-end">
        <Btn primary dusk="createTokenGroupBtn" @click="openModalSlide('CreateTokenGroupSlideover', {})">
            Create Token Group
        </Btn>
    </div>
</div>
<LoadingContent
    class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"
    :is-loading="isLoading"
>
    <table
        id="tokenGroupsTable"
        class="min-w-full divide-y divide-light-stroke dark:divide-dark-stroke"
        v-if="tokenGroups.items?.length"
    >
        <thead>
            <tr>
                <th
                    scope="col"
                    class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-content-strong dark:text-dark-content-strong sm:pl-3"
                >
                    Group ID
                </th>
                <th
                    scope="col"
                    class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-content-strong dark:text-dark-content-strong sm:pl-3"
                >
                    Collection ID
                </th>
                <th
                    scope="col"
                    class="px-3 py-3.5 text-left text-sm font-semibold text-light-content-strong dark:text-dark-content-strong"
                >
                    Tokens
                </th>
                <th
                    scope="col"
                    class="px-3 py-3.5 text-left text-sm font-semibold text-light-content-strong dark:text-dark-content-strong"
                >
                    Attributes
                </th>
                <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3"></th>
            </tr>
        </thead>
        <tbody class="bg-light-surface-primary dark:bg-dark-surface-primary">
            <tr
                v-for="(group, idx) in tokenGroups.items"
                :key="idx"
                :class="
                    idx % 2 === 0
                        ? undefined
                        : 'bg-light-surface-background dark:bg-dark-surface-background !bg-opacity-50'
                "
            >
                <td
                    class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-light-content-strong dark:text-dark-content-strong sm:pl-3"
                >
                    <span
                        class="cursor-pointer"
                        @click="openModalSlide('DetailsTokenGroupSlideover', group)"
                    >
                        {{ `#${group.id}` }}
                    </span>
                </td>
                <td
                    class="whitespace-nowrap px-3 py-4 text-sm text-light-content dark:text-dark-content"
                >
                    #{{ group.collectionId }}
                </td>
                <td
                    class="whitespace-nowrap px-3 py-4 text-sm text-light-content dark:text-dark-content"
                >
                    {{ group.tokensCount }}
                </td>
                <td
                    class="whitespace-nowrap px-3 py-4 text-sm text-light-content dark:text-dark-content"
                >
                    {{ group.attributesCount }}
                </td>
                <td
                    class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-3 flex justify-end"
                >
                    <DropdownMenu
                        :actions="actions"
                        @clicked="($event) => openModalSlide($event, group)"
                    />
                </td>
            </tr>
        </tbody>

@github-actions
Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent undefined list spread

items can become undefined (no edges) and later pagination code spreads
...tokenGroups.value.items, which will throw at runtime. Default items to an empty
array and guard forTokenGroup when GetTokenGroup is null/undefined.

resources/js/factory/token-group.ts [11-29]

 public static forTokenGroups(tokenGroupsData: any) {
     const tokenGroups = tokenGroupsData.data.GetTokenGroups;
 
     return {
-        items: tokenGroups?.edges?.map((edge: any) => {
-            return DTOTokenGroupFactory.buildTokenGroup(edge.node);
-        }),
-        cursor: tokenGroups.pageInfo?.hasNextPage ? tokenGroups.pageInfo.endCursor : null,
+        items:
+            tokenGroups?.edges?.map((edge: any) => {
+                return DTOTokenGroupFactory.buildTokenGroup(edge.node);
+            }) ?? [],
+        cursor: tokenGroups?.pageInfo?.hasNextPage ? tokenGroups.pageInfo.endCursor : null,
     };
 }
 
 public static forTokenGroup(tokenGroupData: any) {
     const tokenGroup = tokenGroupData.data.GetTokenGroup;
+    if (!tokenGroup) {
+        return { items: [], cursor: null };
+    }
 
     return {
         items: [DTOTokenGroupFactory.buildTokenGroup(tokenGroup)],
         cursor: null,
     };
 }
Suggestion importance[1-10]: 8

__

Why: forTokenGroups() can currently return items: undefined when edges is missing, and TokenGroups.vue later does [...tokenGroups.value.items, ...data.items], which would throw. Defaulting items to [] and guarding forTokenGroup() against a null GetTokenGroup prevents a real runtime crash.

Medium
Clean up observers and listeners

The IntersectionObserver and events.on(...) listener are never cleaned up, which can
cause memory leaks and duplicate pagination/transaction handlers when navigating
away and back. Persist the observer instance and unsubscribe/disconnect in an
unmount hook, and guard against observing a null paginatorRef.

resources/js/components/pages/TokenGroups.vue [214-262]

+import { ref, onMounted, Ref, onBeforeUnmount } from 'vue';
+
+const observer = ref<IntersectionObserver | null>(null);
+
 const loadMoreWithObserver = () => {
-    const observer = new IntersectionObserver(
+    observer.value = new IntersectionObserver(
         async (entries) => {
-            if (entries[0].isIntersecting) {
-                try {
-                    if (!tokenGroups.value.cursor || isPaginationLoading.value) return;
-                    isPaginationLoading.value = true;
-                    const collectionId = searchCollectionInput.value || null;
-                    const res = await TokenGroupApi.getTokenGroups(collectionId, tokenGroups.value.cursor);
-                    const data = DTOFactory.forTokenGroups(res);
-                    tokenGroups.value = {
-                        items: [...tokenGroups.value.items, ...data.items],
-                        cursor: data.cursor,
-                    };
-                    isPaginationLoading.value = false;
-                } catch {
-                    isPaginationLoading.value = false;
-                }
+            if (!entries[0]?.isIntersecting) return;
+
+            if (!tokenGroups.value.cursor || isPaginationLoading.value) return;
+            isPaginationLoading.value = true;
+            try {
+                const collectionId = searchCollectionInput.value || null;
+                const res = await TokenGroupApi.getTokenGroups(collectionId, tokenGroups.value.cursor);
+                const data = DTOFactory.forTokenGroups(res);
+                tokenGroups.value = {
+                    items: [...tokenGroups.value.items, ...data.items],
+                    cursor: data.cursor,
+                };
+            } finally {
+                isPaginationLoading.value = false;
             }
         },
         { root: null, rootMargin: '0px', threshold: 1.0 }
     );
-    observer.observe(paginatorRef.value);
+
+    if (paginatorRef.value) observer.value.observe(paginatorRef.value);
 };
 
 onMounted(async () => {
     getTokenGroups();
     loadMoreWithObserver();
     events.on('transaction', openTransactionSlide);
 });
 
+onBeforeUnmount(() => {
+    observer.value?.disconnect();
+    events.off('transaction', openTransactionSlide);
+});
+
Suggestion importance[1-10]: 7

__

Why: The IntersectionObserver and events.on('transaction', ...) registration are created on mount but never cleaned up, which can lead to duplicate handlers and leaks when navigating between routes. Adding onBeforeUnmount() cleanup and guarding paginatorRef is a solid reliability improvement for TokenGroups.vue.

Medium

@zlayine zlayine merged commit 04eaa09 into master Mar 4, 2026
4 checks passed
@zlayine zlayine deleted the feature/PLA-2325/token-groups branch March 4, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

2 participants