sharing updates part 1

This commit is contained in:
Nate Kelley 2025-03-19 16:22:46 -06:00
parent e4a8957d72
commit 4608e8573e
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
15 changed files with 198 additions and 215 deletions

View File

@ -16,9 +16,6 @@ export enum ShareAssetType {
export interface BusterShare {
sharingKey: string;
individual_permissions: null | BusterShareIndividual[];
team_permissions: null | { name: string; id: string; role: ShareRole }[];
organization_permissions: null | [];
password_secret_id: string | null;
public_expiry_date: string | null;
public_enabled_by: string | null;
publicly_accessible: boolean;
@ -29,6 +26,5 @@ export interface BusterShare {
export interface BusterShareIndividual {
email: string;
role: ShareRole;
id: string;
name: string;
name?: string;
}

View File

@ -5,31 +5,19 @@ import { ShareRole } from '../share/shareInterfaces';
*
* @interface ShareRequest
*/
export type ShareRequest = {
/** The unique identifier of the dashboard */
id: string;
/** User-specific permissions array */
user_permissions?: {
/** Email of the user to grant permissions to */
user_email: string;
/** Role to assign to the user */
export type SharePostRequest = {
email: string;
role: ShareRole;
}[];
/** Array of user IDs to remove access from */
remove_users?: string[];
/** Team-specific permissions array */
team_permissions?: {
/** ID of the team to grant permissions to */
team_id: string;
/** Role to assign to the team */
export type ShareDeleteRequest = string[];
export type ShareUpdateRequest = {
users?: {
email: string;
role: ShareRole;
}[];
/** Array of team IDs to remove access from */
remove_teams?: string[];
/** Whether the dashboard is publicly accessible */
publicly_accessible?: boolean;
/** Optional password for public access */
public_password?: string | null;
/** Optional expiration date for public access (timestamptz) */
public_expiry_date?: string | null;
};

View File

@ -1,4 +1,10 @@
import { ShareRole } from '@/api/asset_interfaces';
import type { BusterCollection, BusterCollectionListItem } from '@/api/asset_interfaces/collection';
import {
ShareDeleteRequest,
SharePostRequest,
ShareUpdateRequest
} from '@/api/asset_interfaces/shared_interfaces';
import mainApi from '@/api/buster_rest/instances';
import type {
CreateCollectionParams,
@ -33,3 +39,29 @@ export const collectionsUpdateCollection = async (params: UpdateCollectionParams
export const collectionsDeleteCollection = async (params: DeleteCollectionParams) => {
return await mainApi.delete<BusterCollection>('/collections', { params }).then((res) => res.data);
};
// share collections
export const shareCollection = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApi
.post<BusterCollection>(`/collections/${id}/sharing`, params)
.then((res) => res.data);
};
export const unshareCollection = async ({ id, data }: { id: string; data: ShareDeleteRequest }) => {
return mainApi
.delete<BusterCollection>(`/collections/${id}/sharing`, { data })
.then((res) => res.data);
};
export const updateCollectionShare = async ({
data,
id
}: {
id: string;
data: ShareUpdateRequest;
}) => {
return mainApi
.put<BusterCollection>(`/collections/${id}/sharing`, { data })
.then((res) => res.data);
};

View File

@ -4,7 +4,10 @@ import {
dashboardsGetDashboard,
dashboardsCreateDashboard,
dashboardsUpdateDashboard,
dashboardsDeleteDashboard
dashboardsDeleteDashboard,
shareDashboard,
updateDashboardShare,
unshareDashboard
} from './requests';
import type { DashboardsListRequest } from '@/api/request_interfaces/dashboards/interfaces';
import { dashboardQueryKeys } from '@/api/query_keys/dashboard';
@ -228,3 +231,76 @@ export const useRemoveItemFromDashboard = () => {
mutationFn
});
};
export const useShareDashboard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: shareDashboard,
onMutate: (variables) => {
const queryKey = dashboardQueryKeys.dashboardGetDashboard(variables.id).queryKey;
queryClient.setQueryData(queryKey, (previousData) => {
return create(previousData!, (draft) => {
draft.individual_permissions?.push(...variables.params);
});
});
},
onSuccess: (data) => {
queryClient.setQueryData(
dashboardQueryKeys.dashboardGetDashboard(data.dashboard.id).queryKey,
data
);
}
});
};
export const useUnshareDashboard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: unshareDashboard,
onMutate: (variables) => {
const queryKey = dashboardQueryKeys.dashboardGetDashboard(variables.id).queryKey;
queryClient.setQueryData(queryKey, (previousData) => {
return create(previousData!, (draft) => {
draft.individual_permissions =
draft.individual_permissions?.filter((t) => !variables.data.includes(t.email)) || [];
});
});
},
onSuccess: (data) => {
queryClient.setQueryData(
dashboardQueryKeys.dashboardGetDashboard(data.dashboard.id).queryKey,
data
);
}
});
};
export const useUpdateDashboardShare = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: updateDashboardShare,
onMutate: (variables) => {
const queryKey = dashboardQueryKeys.dashboardGetDashboard(variables.id).queryKey;
queryClient.setQueryData(queryKey, (previousData) => {
return create(previousData!, (draft) => {
draft.individual_permissions =
draft.individual_permissions!.map((t) => {
const found = variables.data.users?.find((v) => v.email === t.email);
if (found) return found;
return t;
}) || [];
if (variables.data.publicly_accessible !== undefined) {
draft.publicly_accessible = variables.data.publicly_accessible;
}
if (variables.data.public_password !== undefined) {
draft.public_password = variables.data.public_password;
}
if (variables.data.public_expiry_date !== undefined) {
draft.public_expiry_date = variables.data.public_expiry_date;
}
});
});
}
});
};

View File

@ -9,6 +9,12 @@ import type {
BusterDashboardListItem,
BusterDashboardResponse
} from '@/api/asset_interfaces/dashboard';
import { ShareRole } from '@/api/asset_interfaces';
import {
ShareDeleteRequest,
SharePostRequest,
ShareUpdateRequest
} from '@/api/asset_interfaces/shared_interfaces';
export const dashboardsGetList = async (params: DashboardsListRequest) => {
return await mainApi
@ -35,3 +41,29 @@ export const dashboardsUpdateDashboard = async (params: DashboardUpdateRequest)
export const dashboardsDeleteDashboard = async ({ ids }: { ids: string[] }) => {
return await mainApi.delete<null>(`/dashboards`, { data: { ids } }).then((res) => res.data);
};
// share dashboards
export const shareDashboard = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApi
.post<BusterDashboardResponse>(`/dashboards/${id}/sharing`, params)
.then((res) => res.data);
};
export const unshareDashboard = async ({ id, data }: { id: string; data: ShareDeleteRequest }) => {
return mainApi
.delete<BusterDashboardResponse>(`/dashboards/${id}/sharing`, { data })
.then((res) => res.data);
};
export const updateDashboardShare = async ({
data,
id
}: {
id: string;
data: ShareUpdateRequest;
}) => {
return mainApi
.put<BusterDashboardResponse>(`/dashboards/${id}/sharing`, { data })
.then((res) => res.data);
};

View File

@ -1,6 +1,5 @@
import type { BusterChartConfigProps } from '@/api/asset_interfaces/metric';
import type { VerificationStatus } from '@/api/asset_interfaces/share';
import type { ShareRequest } from '@/api/asset_interfaces/shared_interfaces';
export interface GetMetricParams {
id: string;
@ -42,4 +41,4 @@ export type UpdateMetricParams = {
status?: VerificationStatus;
/** file in yaml format to update */
file?: string;
} & ShareRequest;
};

View File

@ -6,6 +6,12 @@ import type {
BusterMetricData,
BusterMetricListItem
} from '@/api/asset_interfaces/metric';
import { ShareRole } from '@/api/asset_interfaces/share';
import {
ShareDeleteRequest,
SharePostRequest,
ShareUpdateRequest
} from '@/api/asset_interfaces/shared_interfaces';
export const getMetric = async ({ id, password, version_number }: GetMetricParams) => {
return mainApi
@ -56,3 +62,23 @@ export const duplicateMetric = async (params: {
}) => {
return mainApi.post<BusterMetric>(`/metrics/duplicate`, params).then((res) => res.data);
};
// share metrics
export const shareMetric = async ({ id, params }: { id: string; params: SharePostRequest }) => {
return mainApi.post<BusterMetric>(`/metrics/${id}/sharing`, params).then((res) => res.data);
};
export const unshareMetric = async ({ id, data }: { id: string; data: ShareDeleteRequest }) => {
return mainApi.delete<BusterMetric>(`/metrics/${id}/sharing`, { data }).then((res) => res.data);
};
export const updateMetricShare = async ({
params,
id
}: {
id: string;
params: ShareUpdateRequest;
}) => {
return mainApi.put<BusterMetric>(`/metrics/${id}/sharing`, { params }).then((res) => res.data);
};

View File

@ -1,4 +1,3 @@
import type { ShareRequest } from '@/api/asset_interfaces/shared_interfaces';
import type { ShareAssetType } from '../../asset_interfaces';
export interface GetCollectionListParams {
@ -41,7 +40,7 @@ export type UpdateCollectionParams = {
/** Share request parameters */
share_with?: string[];
share_type?: string;
} & ShareRequest;
};
export interface DeleteCollectionParams {
/** Array of collection IDs to be deleted */

View File

@ -1,6 +1,5 @@
import type { DashboardConfig } from '@/api/asset_interfaces/dashboard';
import { VerificationStatus } from '@/api/asset_interfaces/share';
import type { ShareRequest } from '@/api/asset_interfaces/shared_interfaces';
/**
* Interface for dashboard list request parameters
@ -68,7 +67,7 @@ export type DashboardUpdateRequest = {
metrics?: string[];
/** The file content of the dashboard */
file?: string;
} & ShareRequest;
};
/**
* Interface for deleting dashboards

View File

@ -3,16 +3,11 @@ import { AppAssetCheckLayout } from '@/layouts/AppAssetCheckLayout';
export default async function MetricPage(props: {
params: Promise<{ metricId: string }>;
searchParams: Promise<{ embed?: string }>;
}) {
const searchParams = await props.searchParams;
const params = await props.params;
const { embed } = searchParams;
const { metricId } = params;
const embedView = embed === 'true';
return (
<AppAssetCheckLayout assetId={metricId} type="metric">
<MetricController metricId={metricId} />

View File

@ -1,22 +1,20 @@
import { Avatar } from '@/components/ui/avatar';
import { AccessDropdown } from './AccessDropdown';
import React from 'react';
import { ShareRole } from '@/api/asset_interfaces';
import { Text } from '@/components/ui/typography';
import { useMemoizedFn } from '@/hooks';
export const IndividualSharePerson: React.FC<{
name: string;
name?: string;
email: string;
role: ShareRole;
id: string;
onUpdateShareRole: (id: string, email: string, role: ShareRole | null) => void;
}> = React.memo(({ name, onUpdateShareRole, email, id, role }) => {
onUpdateShareRole: (email: string, role: ShareRole | null) => void;
}> = React.memo(({ name, onUpdateShareRole, email, role }) => {
const isSameEmailName = name === email;
const onChangeShareLevel = useMemoizedFn((v: ShareRole | null) => {
onUpdateShareRole(id, email, v);
onUpdateShareRole(email, v);
});
return (

View File

@ -9,16 +9,15 @@ import { AccessDropdown } from './AccessDropdown';
import { IndividualSharePerson } from './IndividualSharePerson';
import { ShareMenuContentEmbed } from './ShareMenuContentEmbed';
import { ShareMenuContentPublish } from './ShareMenuContentPublish';
import { ShareWithGroupAndTeam } from './ShareWithTeamAndGroup';
import { ShareMenuTopBarOptions } from './ShareMenuTopBar';
import { useUserConfigContextSelector } from '@/context/Users';
import { inputHasText } from '@/lib/text';
import { UserGroup, ChevronRight } from '@/components/ui/icons';
import { cn } from '@/lib/classMerge';
import type { ShareRequest } from '@/api/asset_interfaces/shared_interfaces';
import { useUpdateCollection } from '@/api/buster_rest/collections';
import { useSaveMetric } from '@/api/buster_rest/metrics';
import { useUpdateDashboard } from '@/api/buster_rest/dashboards';
import { ShareUpdateRequest } from '@/api/asset_interfaces/shared_interfaces';
export const ShareMenuContentBody: React.FC<{
selectedOptions: ShareMenuTopBarOptions;
@ -44,8 +43,6 @@ export const ShareMenuContentBody: React.FC<{
const selectedClass = selectedOptions === ShareMenuTopBarOptions.Share ? '' : '';
const individual_permissions = shareAssetConfig.individual_permissions;
const team_permissions = shareAssetConfig.team_permissions;
const organization_permissions = shareAssetConfig.organization_permissions;
const publicly_accessible = shareAssetConfig.publicly_accessible;
const publicExpirationDate = shareAssetConfig.public_expiry_date;
const password = shareAssetConfig.public_password;
@ -57,8 +54,6 @@ export const ShareMenuContentBody: React.FC<{
goBack={goBack}
onCopyLink={onCopyLink}
individual_permissions={individual_permissions}
team_permissions={team_permissions}
organization_permissions={organization_permissions}
publicly_accessible={publicly_accessible}
publicExpirationDate={publicExpirationDate}
password={password}
@ -90,7 +85,6 @@ const ShareMenuContentShare: React.FC<{
ShareRole.CAN_VIEW
);
const disableSubmit = !inputHasText(inputValue) || !validate(inputValue);
const hasUserTeams = userTeams?.length > 0;
const hasIndividualPermissions = !!individual_permissions?.length;
const onSubmitNewEmail = useMemoizedFn(async () => {
@ -122,20 +116,17 @@ const ShareMenuContentShare: React.FC<{
setInputValue('');
});
const onUpdateShareRole = useMemoizedFn(
async (userId: string, email: string, role: ShareRole | null) => {
const payload: ShareRequest = { id: assetId };
if (!role) {
payload.remove_users = [userId];
} else {
payload.user_permissions = [
const onUpdateShareRole = useMemoizedFn(async (email: string, role: ShareRole | null) => {
if (role) {
const payload: ShareUpdateRequest & { id: string } = {
id: assetId,
users: [
{
user_email: email,
email,
role
}
];
}
]
};
if (assetType === ShareAssetType.METRIC) {
await onShareMetric(payload);
} else if (assetType === ShareAssetType.DASHBOARD) {
@ -143,8 +134,9 @@ const ShareMenuContentShare: React.FC<{
} else if (assetType === ShareAssetType.COLLECTION) {
await onShareCollection(payload);
}
} else {
}
);
});
const onChangeInputValue = useMemoizedFn((e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
@ -195,7 +187,7 @@ const ShareMenuContentShare: React.FC<{
<div className="flex flex-col space-y-2 overflow-hidden px-3">
{individual_permissions?.map((permission) => (
<IndividualSharePerson
key={permission.id}
key={permission.email}
{...permission}
onUpdateShareRole={onUpdateShareRole}
/>
@ -241,8 +233,6 @@ const ContentRecord: Record<
goBack: () => void;
onCopyLink: () => void;
individual_permissions: BusterShare['individual_permissions'];
team_permissions: BusterShare['team_permissions'];
organization_permissions: BusterShare['organization_permissions'];
publicly_accessible: boolean;
publicExpirationDate: string | null | undefined;
password: string | null | undefined;
@ -253,6 +243,5 @@ const ContentRecord: Record<
> = {
Share: ShareMenuContentShare,
Embed: ShareMenuContentEmbed,
ShareWithGroupAndTeam: ShareWithGroupAndTeam,
Publish: ShareMenuContentPublish
};

View File

@ -8,8 +8,7 @@ import { type SegmentedItem } from '@/components/ui/segmented';
export enum ShareMenuTopBarOptions {
Share = 'Share',
Publish = 'Publish',
Embed = 'Embed',
ShareWithGroupAndTeam = 'ShareWithGroupAndTeam'
Embed = 'Embed'
}
export const ShareMenuTopBar: React.FC<{

View File

@ -1,144 +0,0 @@
import React, { useMemo } from 'react';
import { CopyLinkButton } from './CopyLinkButton';
import { BackButton, Button } from '@/components/ui/buttons';
import { AccessDropdown } from './AccessDropdown';
import { useUserConfigContextSelector } from '@/context/Users';
import { ShareRole } from '@/api/asset_interfaces';
import { useMemoizedFn } from '@/hooks';
import { Text } from '@/components/ui/typography';
import { UserGroup } from '@/components/ui/icons';
import { ShareAssetType } from '@/api/asset_interfaces';
import type { ShareRequest } from '@/api/asset_interfaces/shared_interfaces';
import { useGetCollection, useUpdateCollection } from '@/api/buster_rest/collections';
import { useGetMetric, useSaveMetric } from '@/api/buster_rest/metrics';
import { useGetDashboard, useUpdateDashboard } from '@/api/buster_rest/dashboards';
export const ShareWithGroupAndTeam: React.FC<{
goBack: () => void;
onCopyLink: () => void;
assetType: ShareAssetType;
assetId: string;
}> = ({ assetType, assetId, goBack, onCopyLink }) => {
const userTeams = useUserConfigContextSelector((state) => state.userTeams);
const { mutateAsync: onShareDashboard } = useUpdateDashboard();
const { mutateAsync: onShareMetric } = useSaveMetric();
const { mutateAsync: onShareCollection } = useUpdateCollection();
const { data: dashboardResponse } = useGetDashboard(
assetType === ShareAssetType.DASHBOARD ? assetId : undefined
);
const { data: collection } = useGetCollection(
assetType === ShareAssetType.COLLECTION ? assetId : undefined
);
const { data: metric } = useGetMetric(
assetType === ShareAssetType.METRIC ? { id: assetId } : { id: undefined }
);
const onUpdateShareRole = useMemoizedFn(
async ({ teamId, role }: { teamId: string; role: ShareRole | null }) => {
const payload: ShareRequest = { id: assetId };
if (!role) {
payload.remove_teams = [teamId];
} else {
payload.team_permissions = [{ team_id: teamId, role }];
}
if (assetType === ShareAssetType.METRIC) {
await onShareMetric(payload);
} else if (assetType === ShareAssetType.DASHBOARD) {
await onShareDashboard(payload);
} else if (assetType === ShareAssetType.COLLECTION) {
await onShareCollection(payload);
}
}
);
const listedTeam: { id: string; name: string; role: ShareRole | null }[] = useMemo(() => {
const assosciatedPermissiongSearch = (teamId: string) => {
if (assetType === ShareAssetType.METRIC && metric) {
return metric.team_permissions?.find((t) => t.id === teamId);
} else if (assetType === ShareAssetType.DASHBOARD && dashboardResponse) {
return dashboardResponse.team_permissions?.find((t) => t.id === teamId);
} else if (assetType === ShareAssetType.COLLECTION && collection) {
return collection.team_permissions?.find((t) => t.id === teamId);
}
};
return userTeams.reduce<{ id: string; name: string; role: ShareRole | null }[]>((acc, team) => {
const assosciatedPermission = assosciatedPermissiongSearch(team.id);
acc.push({
id: team.id,
name: team.name,
role: assosciatedPermission?.role || null
});
return acc;
}, []);
}, [userTeams, dashboardResponse, metric, assetId, collection, assetType]);
const stuffToShow = listedTeam.length > 0 || userTeams.length === 0;
return (
<div className="">
<div className="flex h-[40px] items-center justify-between space-x-1 px-3">
<BackButton onClick={goBack} />
<div>
<CopyLinkButton onCopyLink={onCopyLink} />
</div>
</div>
<div />
<div className="">
{listedTeam.map((team) => (
<ShareOption
key={team.id}
title={userTeams.length > 1 ? team.name : 'Your team'}
role={team.role}
onUpdateShareRole={(role) => {
onUpdateShareRole({
teamId: team.id,
role
});
}}
/>
))}
{userTeams.length === 0 && (
<div className="flex w-full items-center justify-center p-3">
<Text variant="secondary">Not currently a member of any teams</Text>
</div>
)}
{!stuffToShow && (
<div className="flex w-full items-center justify-center p-3">
<Text variant="secondary">No teams to share with</Text>
</div>
)}
</div>
</div>
);
};
const ShareOption: React.FC<{
title: string;
onUpdateShareRole: (role: ShareRole | null) => void;
role: ShareRole | null;
}> = ({ onUpdateShareRole, title, role }) => {
return (
<div className={'flex h-[40px] cursor-pointer items-center justify-between space-x-2 px-3'}>
<div className="flex items-center space-x-2">
<Button prefix={<UserGroup />} />
<Text>{title}</Text>
</div>
<div>
<AccessDropdown
groupShare
shareLevel={role}
onChangeShareLevel={(v) => {
onUpdateShareRole(v);
}}
/>
</div>
</div>
);
};

View File

@ -56,7 +56,6 @@ export const SelectChartType: React.FC<SelectChartTypeProps> = ({
const onSelectChartType = useMemoizedFn((chartIconType: ChartIconType) => {
const chartConfig = selectedChartTypeMethod(chartIconType, columnSettings);
console.log('chartConfig', chartConfig);
onUpdateMetricChartConfig({ chartConfig });
});