optimistic updates for assigning permission groups

This commit is contained in:
Nate Kelley 2025-01-10 14:41:33 -07:00
parent ae689150b3
commit 1671c2d8fb
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
16 changed files with 407 additions and 115 deletions

View File

@ -9,8 +9,9 @@ import {
updatePermissionUsers
} from './requests';
import { useMemoizedFn } from 'ahooks';
import { QueryClient } from '@tanstack/react-query';
import { QueryClient, useQueryClient } from '@tanstack/react-query';
import { getDatasetPermissionsOverview_server } from './serverRequests';
import { ListPermissionUsersResponse } from './responseInterfaces';
export const useGetDatasetPermissionsOverview = (dataset_id: string) => {
const queryFn = useMemoizedFn(() => getDatasetPermissionsOverview({ dataset_id }));
@ -38,7 +39,8 @@ export const useListPermissionGroups = (dataset_id: string) => {
return useCreateReactQuery({
queryKey: ['list_permission_groups', dataset_id],
queryFn
queryFn,
staleTime: 1000 * 5 // 5 seconds
});
};
@ -61,43 +63,77 @@ export const useListPermissionUsers = (dataset_id: string) => {
};
export const useUpdatePermissionGroups = (dataset_id: string) => {
const mutationFn = useMemoizedFn((groups: { id: string; assigned: boolean }[]) =>
updatePermissionGroups({ dataset_id, groups })
);
const onSuccess = useMemoizedFn(() => {
// queryClient.invalidateQueries({ queryKey: ['dataset_permissions_overview', dataset_id] });
const queryClient = useQueryClient();
const mutationFn = useMemoizedFn((groups: { id: string; assigned: boolean }[]) => {
const keyedChanges: Record<string, { id: string; assigned: boolean }> = {};
groups.forEach(({ id, assigned }) => {
keyedChanges[id] = { id, assigned };
});
queryClient.setQueryData(
['list_permission_groups', dataset_id],
(oldData: ListPermissionUsersResponse[]) => {
return oldData?.map((group) => {
const updatedGroup = keyedChanges[group.id];
if (updatedGroup) return { ...group, assigned: updatedGroup.assigned };
return group;
});
}
);
return updatePermissionGroups({ dataset_id, groups });
});
return useCreateReactMutation({
mutationFn,
onSuccess
mutationFn
});
};
export const useUpdateDatasetGroups = (dataset_id: string) => {
const mutationFn = useMemoizedFn((groups: { id: string; assigned: boolean }[]) =>
updateDatasetGroups({ dataset_id, groups })
);
const onSuccess = useMemoizedFn(() => {
// queryClient.invalidateQueries({ queryKey: ['dataset_permissions_overview', dataset_id] });
const queryClient = useQueryClient();
const mutationFn = useMemoizedFn((groups: { id: string; assigned: boolean }[]) => {
const keyedChanges: Record<string, { id: string; assigned: boolean }> = {};
groups.forEach(({ id, assigned }) => {
keyedChanges[id] = { id, assigned };
});
queryClient.setQueryData(
['list_dataset_groups', dataset_id],
(oldData: ListPermissionUsersResponse[]) => {
return oldData?.map((group) => {
const updatedGroup = keyedChanges[group.id];
if (updatedGroup) return { ...group, assigned: updatedGroup.assigned };
return group;
});
}
);
return updateDatasetGroups({ dataset_id, groups });
});
return useCreateReactMutation({
mutationFn,
onSuccess
mutationFn
});
};
export const useUpdatePermissionUsers = (dataset_id: string) => {
const mutationFn = useMemoizedFn((users: { id: string; assigned: boolean }[]) =>
updatePermissionUsers({ dataset_id, users })
);
const onSuccess = useMemoizedFn(() => {
// queryClient.invalidateQueries({ queryKey: ['dataset_permissions_overview', dataset_id] });
const queryClient = useQueryClient();
const mutationFn = useMemoizedFn((users: { id: string; assigned: boolean }[]) => {
const keyedChanges: Record<string, { id: string; assigned: boolean }> = {};
users.forEach(({ id, assigned }) => {
keyedChanges[id] = { id, assigned };
});
queryClient.setQueryData(
['list_permission_users', dataset_id],
(oldData: ListPermissionUsersResponse[]) => {
return oldData?.map((user) => {
const updatedUser = keyedChanges[user.id];
if (updatedUser) return { ...user, assigned: updatedUser.assigned };
return user;
});
}
);
return updatePermissionUsers({ dataset_id, users });
});
return useCreateReactMutation({
mutationFn,
onSuccess
mutationFn
});
};

View File

@ -50,7 +50,12 @@ export const updatePermissionGroups = async ({
}: {
dataset_id: string;
groups: { id: string; assigned: boolean }[];
}): Promise<void> => {
}): Promise<
{
id: string;
assigned: boolean;
}[]
> => {
return await mainApi.put(`/datasets/${dataset_id}/permission_groups`, groups);
};

View File

@ -7,3 +7,4 @@ export * from './search';
export * from './assets';
export * from './api_keys';
export * from './sql';
export * from './datasets';

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Title, Text } from '@/components';
export const HeaderExplanation: React.FC<{
className?: string;
title?: string;
description?: string;
}> = React.memo(
({
className = '',
title = 'Access & lineage',
description = 'View which users can query this dataset. Lineage is provided to show where each users access originates from.'
}) => {
return (
<div className={`flex flex-col space-y-1.5 ${className}`}>
<Title level={4}>{title}</Title>
<Text type="secondary">{description}</Text>
</div>
);
}
);
HeaderExplanation.displayName = 'HeaderExplanation';

View File

@ -1,7 +0,0 @@
import React from 'react';
export const PermissionPermissionGroup: React.FC<{}> = React.memo(({}) => {
return <div>asdf</div>;
});
PermissionPermissionGroup.displayName = 'PermissionPermissionGroup';

View File

@ -3,20 +3,21 @@ import { Input } from 'antd';
import { AppMaterialIcons } from '@/components';
import { useMemoizedFn } from 'ahooks';
export const PermissionOverviewSearch: React.FC<{
export const PermissionSearch: React.FC<{
className?: string;
searchText: string;
setSearchText: (text: string) => void;
}> = ({ className = '', searchText, setSearchText }) => {
placeholder?: string;
}> = ({ className = '', searchText, setSearchText, placeholder = 'Search by name or email' }) => {
const onChange = useMemoizedFn((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
});
return (
<div className={`flex flex-col space-y-1.5 ${className}`}>
<div className={`flex w-full flex-col space-y-1.5 ${className}`}>
<Input
className="max-w-[280px]"
placeholder="Search by name or email"
className="w-[280px] max-w-[280px]"
placeholder={placeholder}
value={searchText}
onChange={onChange}
allowClear
@ -25,4 +26,4 @@ export const PermissionOverviewSearch: React.FC<{
</div>
);
};
PermissionOverviewSearch.displayName = 'PermissionOverviewSearch';
PermissionSearch.displayName = 'PermissionOverviewSearch';

View File

@ -6,7 +6,7 @@ import { AnimatePresence, motion } from 'framer-motion';
import { PermissionApps } from './config';
import { PermissionDatasetGroups } from './PermissionDatasetGroups';
import { PermissionOverview } from './_PermissionOverview';
import { PermissionPermissionGroup } from './PermissionPermissionGroup';
import { PermissionPermissionGroup } from './_PermissionPermissionGroup/PermissionPermissionGroup';
import { PermissionUsers } from './PermissionUsers';
const selectedAppComponent: Record<PermissionApps, React.FC<{ datasetId: string }>> = {
@ -26,7 +26,8 @@ export const PermissionsAppContainer: React.FC<{ datasetId: string }> = React.me
return {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 }
exit: { opacity: 0 },
transition: { duration: 0.125 }
};
}, []);

View File

@ -1,17 +0,0 @@
import React from 'react';
import { Title, Text } from '@/components';
export const HeaderExplanation: React.FC<{ className?: string }> = React.memo(
({ className = '' }) => {
return (
<div className={`flex flex-col space-y-1.5 ${className}`}>
<Title level={4}>Access & lineage</Title>
<Text type="secondary">
{`View which users can query this dataset. Lineage is provided to show where each users
access originates from.`}
</Text>
</div>
);
}
);
HeaderExplanation.displayName = 'HeaderExplanation';

View File

@ -1,10 +0,0 @@
import React from 'react';
import { DatasetPermissionOverviewUser } from '@/api/busterv2/datasets';
export const PermissionListUser: React.FC<{
className?: string;
user: DatasetPermissionOverviewUser;
}> = React.memo(({ className = '', user }) => {
return <div className={`flex flex-col space-y-1.5 ${className}`}>swag</div>;
});
PermissionListUser.displayName = 'PermissionListUser';

View File

@ -15,26 +15,29 @@ export const PermissionListUserContainer: React.FC<{
const numberOfUsers = filteredUsers.length;
const columns: BusterListColumn[] = [
{
title: 'Name',
dataIndex: 'name',
width: 290,
render: (_: string, user: DatasetPermissionOverviewUser) => {
return <UserInfoCell user={user} />;
const columns: BusterListColumn[] = useMemo(
() => [
{
title: 'Name',
dataIndex: 'name',
width: 290,
render: (_: string, user: DatasetPermissionOverviewUser) => {
return <UserInfoCell user={user} />;
}
},
{
title: 'Lineage',
dataIndex: 'lineage',
render: (
lineage: DatasetPermissionOverviewUser['lineage'],
user: DatasetPermissionOverviewUser
) => {
return <UserLineageCell user={user} />;
}
}
},
{
title: 'Lineage',
dataIndex: 'lineage',
render: (
lineage: DatasetPermissionOverviewUser['lineage'],
user: DatasetPermissionOverviewUser
) => {
return <UserLineageCell user={user} />;
}
}
];
],
[]
);
const { cannotQueryUsers, canQueryUsers } = useMemo(() => {
const result: {
@ -66,28 +69,32 @@ export const PermissionListUserContainer: React.FC<{
return result;
}, [filteredUsers]);
const rows = [
{
id: 'header-assigned',
data: {},
hidden: canQueryUsers.length === 0,
rowSection: {
title: 'Assigned',
secondaryTitle: numberOfUsers.toString()
}
},
...canQueryUsers,
{
id: 'header-not-assigned',
data: {},
hidden: cannotQueryUsers.length === 0,
rowSection: {
title: 'Not Assigned',
secondaryTitle: cannotQueryUsers.length.toString()
}
},
...cannotQueryUsers
].filter((row) => !(row as any).hidden);
const rows = useMemo(
() =>
[
{
id: 'header-assigned',
data: {},
hidden: canQueryUsers.length === 0,
rowSection: {
title: 'Assigned',
secondaryTitle: numberOfUsers.toString()
}
},
...canQueryUsers,
{
id: 'header-not-assigned',
data: {},
hidden: cannotQueryUsers.length === 0,
rowSection: {
title: 'Not Assigned',
secondaryTitle: cannotQueryUsers.length.toString()
}
},
...cannotQueryUsers
].filter((row) => !(row as any).hidden),
[canQueryUsers, cannotQueryUsers, numberOfUsers]
);
return (
<>

View File

@ -3,13 +3,10 @@ import {
useGetDatasetPermissionsOverview
} from '@/api/busterv2/datasets';
import React, { useMemo, useState, useTransition, useEffect } from 'react';
import { Title, Text } from '@/components/text';
import { Input } from 'antd';
import { useMemoizedFn } from 'ahooks';
import { AppMaterialIcons } from '@/components';
import { useDebounceFn } from 'ahooks';
import { HeaderExplanation } from './HeaderExplanation';
import { PermissionOverviewSearch } from './PermissionOverviewSearch';
import { HeaderExplanation } from '../HeaderExplanation';
import { PermissionSearch } from '../PermissionSearch';
import { PermissionListUserContainer } from './PermissionListUserContainer';
export const PermissionOverview: React.FC<{
@ -65,7 +62,7 @@ export const PermissionOverview: React.FC<{
<>
<HeaderExplanation className="mb-5" />
<div className="flex h-full flex-col space-y-3">
<PermissionOverviewSearch searchText={searchText} setSearchText={handleSearchChange} />
<PermissionSearch searchText={searchText} setSearchText={handleSearchChange} />
<PermissionListUserContainer filteredUsers={filteredUsers} />
{/* You can use filteredUsers here to display the results */}
</div>

View File

@ -0,0 +1,178 @@
import { ListPermissionGroupsResponse, useUpdatePermissionGroups } from '@/api/busterv2/datasets';
import { BusterListColumn, BusterListRowItem } from '@/components/list';
import { BusterInfiniteList } from '@/components/list/BusterInfiniteList';
import { useMemoizedFn } from 'ahooks';
import { Select } from 'antd';
import { createStyles } from 'antd-style';
import React, { useMemo, useState } from 'react';
export const PermissionListPermissionGroupContainer: React.FC<{
filteredPermissionGroups: ListPermissionGroupsResponse[];
datasetId: string;
}> = React.memo(({ filteredPermissionGroups, datasetId }) => {
const { styles, cx } = useStyles();
const { mutateAsync: updatePermissionGroups } = useUpdatePermissionGroups(datasetId);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const numberOfPermissionGroups = filteredPermissionGroups.length;
const onSelectAssigned = useMemoizedFn(async (params: { id: string; assigned: boolean }) => {
updatePermissionGroups([params]);
});
const columns: BusterListColumn[] = useMemo(
() => [
{
title: 'Name',
dataIndex: 'name',
width: 270,
render: (name: string) => {
return <PermissionGroupInfoCell name={name} />;
}
},
{
title: 'Assigned',
dataIndex: 'assigned',
render: (assigned: boolean, permissionGroup: ListPermissionGroupsResponse) => {
return (
<div className="flex justify-end">
<PermissionGroupAssignedCell
id={permissionGroup.id}
assigned={assigned}
onSelect={onSelectAssigned}
/>
</div>
);
}
}
],
[]
);
const { cannotQueryPermissionGroups, canQueryPermissionGroups } = useMemo(() => {
const result: {
cannotQueryPermissionGroups: BusterListRowItem[];
canQueryPermissionGroups: BusterListRowItem[];
} = filteredPermissionGroups.reduce<{
cannotQueryPermissionGroups: BusterListRowItem[];
canQueryPermissionGroups: BusterListRowItem[];
}>(
(acc, permissionGroup) => {
if (permissionGroup.assigned) {
acc.canQueryPermissionGroups.push({
id: permissionGroup.id,
data: permissionGroup
});
} else {
acc.cannotQueryPermissionGroups.push({
id: permissionGroup.id,
data: permissionGroup
});
}
return acc;
},
{
cannotQueryPermissionGroups: [] as BusterListRowItem[],
canQueryPermissionGroups: [] as BusterListRowItem[]
}
);
return result;
}, [filteredPermissionGroups]);
const rows = useMemo(
() =>
[
{
id: 'header-assigned',
data: {},
hidden: canQueryPermissionGroups.length === 0,
rowSection: {
title: 'Assigned',
secondaryTitle: canQueryPermissionGroups.length.toString()
}
},
...canQueryPermissionGroups,
{
id: 'header-not-assigned',
data: {},
hidden: cannotQueryPermissionGroups.length === 0,
rowSection: {
title: 'Not Assigned',
secondaryTitle: cannotQueryPermissionGroups.length.toString()
}
},
...cannotQueryPermissionGroups
].filter((row) => !(row as any).hidden),
[canQueryPermissionGroups, cannotQueryPermissionGroups, numberOfPermissionGroups]
);
return (
<>
<div className={cx('', styles.container)}>
<BusterInfiniteList
columns={columns}
rows={rows}
showHeader={false}
showSelectAll={false}
selectedRowKeys={selectedRowKeys}
onSelectChange={setSelectedRowKeys}
onScrollEnd={() => {
console.log('scrolled');
}}
emptyState={<div className="py-12">No teams found</div>}
/>
</div>
</>
);
});
PermissionListPermissionGroupContainer.displayName = 'PermissionListTeamContainer';
const useStyles = createStyles(({ css, token }) => ({
container: css`
border: 0.5px solid ${token.colorBorder};
border-radius: ${token.borderRadius}px;
overflow: hidden;
`
}));
const PermissionGroupInfoCell = React.memo(({ name }: { name: string }) => {
return <div>{name}</div>;
});
const options = [
{
label: 'Assigned',
value: true
},
{
label: 'Not Assigned',
value: false
}
];
const PermissionGroupAssignedCell = React.memo(
({
id,
assigned,
onSelect
}: {
id: string;
assigned: boolean;
onSelect: (value: { id: string; assigned: boolean }) => void;
}) => {
return (
<Select
options={options}
defaultValue={assigned}
popupMatchSelectWidth
onSelect={(value) => {
onSelect({ id, assigned: value });
}}
/>
);
},
() => true
);
PermissionGroupAssignedCell.displayName = 'PermissionGroupAssignedCell';

View File

@ -0,0 +1,78 @@
import React, { useEffect, useState, useTransition } from 'react';
import { HeaderExplanation } from '../HeaderExplanation';
import { PermissionSearch } from '../PermissionSearch';
import { useDebounceFn, useMemoizedFn } from 'ahooks';
import { Button } from 'antd';
import { AppMaterialIcons } from '@/components';
import { PermissionListPermissionGroupContainer } from './PermissionListPermissionGroupContainer';
import { ListPermissionGroupsResponse, useListPermissionGroups } from '@/api/busterv2/datasets';
export const PermissionPermissionGroup: React.FC<{
datasetId: string;
}> = React.memo(({ datasetId }) => {
const [isPending, startTransition] = useTransition();
const { data: permissionGroups } = useListPermissionGroups(datasetId);
const [searchText, setSearchText] = useState('');
const [filteredPermissionGroups, setFilteredPermissionGroups] = useState<
ListPermissionGroupsResponse[]
>([]);
const filterPermissionGroups = useMemoizedFn((text: string): ListPermissionGroupsResponse[] => {
if (!text) return permissionGroups || [];
const lowerCaseSearchText = text.toLowerCase();
return (permissionGroups || []).filter((p) => {
return p.name.toLowerCase().includes(lowerCaseSearchText);
});
});
const updateFilteredPermissionGroups = useMemoizedFn((text: string) => {
startTransition(() => {
setFilteredPermissionGroups(filterPermissionGroups(text));
});
});
const { run: debouncedSearch } = useDebounceFn(
(text: string) => {
updateFilteredPermissionGroups(text);
},
{ wait: 300 }
);
const handleSearchChange = useMemoizedFn((text: string) => {
setSearchText(text);
debouncedSearch(text);
});
useEffect(() => {
setFilteredPermissionGroups(permissionGroups || []);
}, [permissionGroups]);
return (
<>
<HeaderExplanation
className="mb-5"
title="Dataset permissions"
description="Manage who can build dashboards & metrics using this dataset"
/>
<div className="flex h-full flex-col space-y-3">
<div className="flex items-center justify-between">
<PermissionSearch
searchText={searchText}
setSearchText={handleSearchChange}
placeholder="Search by permission group"
/>
<Button type="default" icon={<AppMaterialIcons icon="add" />}>
New permission group
</Button>
</div>
<PermissionListPermissionGroupContainer
filteredPermissionGroups={filteredPermissionGroups}
datasetId={datasetId}
/>
</div>
</>
);
});
PermissionPermissionGroup.displayName = 'PermissionPermissionGroup';

View File

@ -0,0 +1 @@
export * from './PermissionPermissionGroup';

View File

@ -165,8 +165,6 @@ export const ThreadItemsContainer: React.FC<{
[]
);
console.log(threadsByDate);
return (
<div
ref={tableContainerRef}

View File

@ -0,0 +1 @@
export * from './BusterInfiniteList';