') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); feat(shared): Organization hooks with `setData` and `revalidate` by panteliselef · Pull Request #1745 · clerk/javascript · GitHub
Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/blue-ghosts-float.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@clerk/shared': minor
---

Expose `revalidate` and `setData` for paginated lists of data in organization hooks.
`const {userMemberships:{revalidate, setData}} = useOrganizationList({userMemberships:true})`
Original file line numberDiff line numberDiff line change
Expand Up@@ -5,7 +5,7 @@ import { useCoreClerk, useCoreOrganizationList } from '../../contexts';
import { localizationKeys } from '../../customizables';
import { useCardState, withCardStateProvider } from '../../elements';
import { handleError } from '../../utils';
import { updateCacheInPlace } from '../OrganizationSwitcher/utils';
import { populateCacheUpdateItem } from '../OrganizationSwitcher/utils';
import { PreviewListItem, PreviewListItemButton } from './shared';
import { MembershipPreview } from './UserMembershipList';
import { organizationListParams } from './utils';
Expand DownExpand Up@@ -39,7 +39,7 @@ export const InvitationPreview = withCardStateProvider((props: UserOrganizationI
})
.then(([updatedItem, organization]) => {
// Update cache in case another listener depends on it
updateCacheInPlace(userInvitations)(updatedItem);
void userInvitations?.setData?.(cachedPages => populateCacheUpdateItem(updatedItem, cachedPages));
setAcceptedOrganization(organization);
})
.catch(err => handleError(err, [], card.setError));
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ import { useCoreOrganizationList } from '../../contexts';
import { localizationKeys, Text } from '../../customizables';
import { useCardState, withCardStateProvider } from '../../elements';
import { handleError } from '../../utils';
import { updateCacheInPlace } from '../OrganizationSwitcher/utils';
import { populateCacheUpdateItem } from '../OrganizationSwitcher/utils';
import { PreviewListItem, PreviewListItemButton } from './shared';
import { organizationListParams } from './utils';

Expand All@@ -17,7 +17,7 @@ export const AcceptRejectInvitationButtons = (props: OrganizationSuggestionResou
const handleAccept = () => {
return card
.runAsync(props.accept)
.then(updateCacheInPlace(userSuggestions))
.then(updatedItem => userSuggestions?.setData?.(pages => populateCacheUpdateItem(updatedItem, pages)))
.catch(err => handleError(err, [], card.setError));
};

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,25 +9,18 @@ import { DataTable, RoleSelect, RowContainer } from './MemberListTable';

export const ActiveMembersList = () => {
const card = useCardState();
const { organization, memberships, ...rest } = useCoreOrganization({
const { organization, memberships } = useCoreOrganization({
memberships: true,
});

const mutateSwrState = () => {
const unstable__mutate = (rest as any).unstable__mutate;
if (unstable__mutate && typeof unstable__mutate === 'function') {
unstable__mutate();
}
};

if (!organization) {
return null;
}

const handleRoleChange = (membership: OrganizationMembershipResource) => (newRole: MembershipRole) => {
return card
.runAsync(async () => {
await membership.update({ role: newRole });
return await membership.update({ role: newRole });
})
.catch(err => handleError(err, [], card.setError));
};
Expand All@@ -36,9 +29,9 @@ export const ActiveMembersList = () => {
return card
.runAsync(async () => {
const destroyedMembership = await membership.destroy();
await memberships?.revalidate?.();
return destroyedMembership;
})
.then(mutateSwrState)
.catch(err => handleError(err, [], card.setError));
};

Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,7 +66,7 @@ export const RemoveDomainPage = () => {
successMessage={localizationKeys('organizationProfile.removeDomainPage.successMessage', {
domain: ref.current?.name,
})}
deleteResource={() => domain?.delete().then(() => (domains as any).unstable__mutate())}
deleteResource={() => domain?.delete().then(() => domains?.revalidate?.())}
breadcrumbTitle={localizationKeys('organizationProfile.profilePage.domainSection.title')}
Breadcrumbs={OrganizationProfileBreadcrumbs}
/>
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,23 +49,29 @@ const RequestRow = withCardStateProvider(
(props: { request: OrganizationMembershipRequestResource; onError: ReturnType<typeof useCardState>['setError'] }) => {
const { request, onError } = props;
const card = useCardState();
const { membershipRequests } = useCoreOrganization({
const { membership, membershipRequests } = useCoreOrganization({
membershipRequests: membershipRequestsParams,
});

const onAccept = () => {
if (!membership || !membershipRequests) {
return;
}
return card
.runAsync(async () => {
await request.accept();
await (membershipRequests as any).unstable__mutate?.();
await membershipRequests.revalidate();
}, 'accept')
.catch(err => handleError(err, [], onError));
};
const onReject = () => {
if (!membership || !membershipRequests) {
return;
}
return card
.runAsync(async () => {
await request.reject();
await (membershipRequests as any).unstable__mutate?.();
await membershipRequests.revalidate();
}, 'reject')
.catch(err => handleError(err, [], onError));
};
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -55,7 +55,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
const card = useCardState();
const { organizationSettings } = useEnvironment();

const { organization, domains } = useCoreOrganization({
const { membership, organization, domains } = useCoreOrganization({
domains: {
infinite: true,
},
Expand DownExpand Up@@ -147,7 +147,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
});

const updateEnrollmentMode = async () => {
if (!domain || !organization) {
if (!domain || !organization || !membership || !domains) {
return;
}

Expand All@@ -157,7 +157,7 @@ export const VerifiedDomainPage = withCardStateProvider(() => {
deletePending: deletePending.checked,
});

await (domains as any).unstable__mutate();
await domains.revalidate();

await navigate('../../');
} catch (e) {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,7 +9,7 @@ import { useInView } from '../../hooks';
import type { PropsOfComponent } from '../../styledSystem';
import { common } from '../../styledSystem';
import { handleError } from '../../utils';
import { organizationListParams, removeItemFromPaginatedCache, updateCacheInPlace } from './utils';
import { organizationListParams, populateCacheRemoveItem, populateCacheUpdateItem } from './utils';

const useFetchInvitations = () => {
const { userInvitations, userSuggestions } = useCoreOrganizationList(organizationListParams);
Expand DownExpand Up@@ -44,7 +44,7 @@ const AcceptRejectSuggestionButtons = (props: OrganizationSuggestionResource) =>
const handleAccept = () => {
return card
.runAsync(props.accept)
.then(updateCacheInPlace(userSuggestions))
.then(updatedItem => userSuggestions?.setData?.(pages => populateCacheUpdateItem(updatedItem, pages)))
.catch(err => handleError(err, [], card.setError));
};

Expand DownExpand Up@@ -81,7 +81,7 @@ const AcceptRejectInvitationButtons = (props: UserOrganizationInvitationResource
const handleAccept = () => {
return card
.runAsync(props.accept)
.then(removeItemFromPaginatedCache(userInvitations))
.then(updatedItem => userInvitations?.setData?.(pages => populateCacheRemoveItem(updatedItem, pages)))
.catch(err => handleError(err, [], card.setError));
};

Expand Down
52 changes: 26 additions & 26 deletions packages/clerk-js/src/ui/components/OrganizationSwitcher/utils.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,9 +17,20 @@ export const organizationListParams = {

export const populateCacheUpdateItem = <T extends { id: string }>(
updatedItem: T,
itemsInfinitePages: ClerkPaginatedResponse<T>[],
itemsInfinitePages: (ClerkPaginatedResponse<T> | undefined)[] | undefined,
) => {
if (typeof itemsInfinitePages === 'undefined') {
return [{ data: [updatedItem], total_count: 1 }];
}

/**
* We should "preserve" an undefined page if one is found. For example if swr triggers 2 requests, page 1 & page2, and the request for page2 resolves first, at that point in memory itemsInfinitePages would look like this [undefined, {....}]
* if SWR says that has fetched 2 pages but the first result of is undefined, we should not return back an array with 1 item as this will end up having cacheKeys that point nowhere.
*/
return itemsInfinitePages.map(item => {
if (typeof item === 'undefined') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if(typeofitem==='undefined'){
if(!item){return}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gentle reminder ☝️

return item;
}
const newData = item.data.map(obj => {
if (obj.id === updatedItem.id) {
return {
Expand All@@ -33,38 +44,27 @@ export const populateCacheUpdateItem = <T extends { id: string }>(
});
};

export const updateCacheInPlace =
(userSuggestions: any) =>
<T extends { id: string }>(result: T): any => {
userSuggestions?.unstable__mutate?.(result, {
populateCache: populateCacheUpdateItem,
// Since `accept` gives back the updated information,
// we don't need to revalidate here.
revalidate: false,
});
};

export const populateCacheRemoveItem = <T extends { id: string }>(
updatedItem: T,
itemsInfinitePages: ClerkPaginatedResponse<T>[],
itemsInfinitePages: (ClerkPaginatedResponse<T> | undefined)[] | undefined,
) => {
const prevTotalCount = itemsInfinitePages[itemsInfinitePages.length - 1].total_count;
const prevTotalCount = itemsInfinitePages?.[itemsInfinitePages.length - 1]?.total_count;

return itemsInfinitePages.map(item => {
if (!prevTotalCount) {
return undefined;
}

/**
* We should "preserve" an undefined page if one is found. For example if swr triggers 2 requests, page 1 & page2, and the request for page2 resolves first, at that point in memory itemsInfinitePages would look like this [undefined, {....}]
* if SWR says that has fetched 2 pages but the first result of is undefined, we should not return back an array with 1 item as this will end up having cacheKeys that point nowhere.
*/
return itemsInfinitePages?.map(item => {
if (typeof item === 'undefined') {
Comment thread
panteliselef marked this conversation as resolved.
return item;
}
const newData = item.data.filter(obj => {
return obj.id !== updatedItem.id;
});
return { ...item, data: newData, total_count: prevTotalCount - 1 };
});
};

export const removeItemFromPaginatedCache =
(userInvitations: any) =>
<T extends { id: string }>(result: T): any => {
userInvitations?.unstable__mutate?.(result, {
populateCache: populateCacheRemoveItem,
// Since `accept` gives back the updated information,
// we don't need to revalidate here.
revalidate: false,
});
};
28 changes: 21 additions & 7 deletions packages/shared/src/react/hooks/useOrganization.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -56,7 +56,9 @@ type UseOrganizationParams = {
});
};

type UseOrganizationReturn =
type UseOrganization = <T extends UseOrganizationParams>(
params?: T,
) =>
| {
isLoaded: false;
organization: undefined;
Expand DownExpand Up@@ -103,14 +105,24 @@ type UseOrganizationReturn =
*/
membershipList: OrganizationMembershipResource[] | null | undefined;
membership: OrganizationMembershipResource | null | undefined;
domains: PaginatedResources<OrganizationDomainResource> | null;
membershipRequests: PaginatedResources<OrganizationMembershipRequestResource> | null;
memberships: PaginatedResources<OrganizationMembershipResource> | null;
invitations: PaginatedResources<OrganizationInvitationResource> | null;
domains: PaginatedResources<
OrganizationDomainResource,
T['membershipRequests'] extends { infinite: true } ? true : false
> | null;
membershipRequests: PaginatedResources<
OrganizationMembershipRequestResource,
T['membershipRequests'] extends { infinite: true } ? true : false
> | null;
memberships: PaginatedResources<
OrganizationMembershipResource,
T['memberships'] extends { infinite: true } ? true : false
> | null;
invitations: PaginatedResources<
OrganizationInvitationResource,
T['invitations'] extends { infinite: true } ? true : false
> | null;
};

type UseOrganization = (params?: UseOrganizationParams) => UseOrganizationReturn;

const undefinedPaginatedResource = {
data: undefined,
count: undefined,
Expand All@@ -124,6 +136,8 @@ const undefinedPaginatedResource = {
fetchPrevious: undefined,
hasNextPage: false,
hasPreviousPage: false,
revalidate: undefined,
setData: undefined,
} as const;

export const useOrganization: UseOrganization = params => {
Expand Down
53 changes: 31 additions & 22 deletions packages/shared/src/react/hooks/useOrganizationList.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,8 +38,26 @@ type UseOrganizationListParams = {
};

type OrganizationList = ReturnType<typeof createOrganizationList>;
const undefinedPaginatedResource = {
data: undefined,
count: undefined,
isLoading: false,
isFetching: false,
isError: false,
page: undefined,
pageCount: undefined,
fetchPage: undefined,
fetchNext: undefined,
fetchPrevious: undefined,
hasNextPage: false,
hasPreviousPage: false,
revalidate: undefined,
setData: undefined,
} as const;

type UseOrganizationListReturn =
type UseOrganizationList = <T extends UseOrganizationListParams>(
params?: T,
) =>
| {
isLoaded: false;
/**
Expand All@@ -60,29 +78,20 @@ type UseOrganizationListReturn =
organizationList: OrganizationList;
createOrganization: (params: CreateOrganizationParams) => Promise<OrganizationResource>;
setActive: SetActive;
userMemberships: PaginatedResources<OrganizationMembershipResource>;
userInvitations: PaginatedResources<UserOrganizationInvitationResource>;
userSuggestions: PaginatedResources<OrganizationSuggestionResource>;
userMemberships: PaginatedResources<
OrganizationMembershipResource,
T['userMemberships'] extends { infinite: true } ? true : false
>;
userInvitations: PaginatedResources<
UserOrganizationInvitationResource,
T['userInvitations'] extends { infinite: true } ? true : false
>;
userSuggestions: PaginatedResources<
OrganizationSuggestionResource,
T['userSuggestions'] extends { infinite: true } ? true : false
>;
};

const undefinedPaginatedResource = {
data: undefined,
count: undefined,
isLoading: false,
isFetching: false,
isError: false,
page: undefined,
pageCount: undefined,
fetchPage: undefined,
fetchNext: undefined,
fetchPrevious: undefined,
hasNextPage: false,
hasPreviousPage: false,
unstable__mutate: undefined,
} as const;

type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizationListReturn;

export const useOrganizationList: UseOrganizationList = params => {
const { userMemberships, userInvitations, userSuggestions } = params || {};

Expand Down
Loading