apps/chat/src/components/Chat/Publish/PublicationItemsList.tsx (553 lines of code) (raw):
import { IconAlertCircle, IconDownload } from '@tabler/icons-react';
import {
ChangeEvent,
ReactNode,
memo,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'next-i18next';
import classNames from 'classnames';
import { findLatestVersion, isVersionValid } from '@/src/utils/app/common';
import { constructPath } from '@/src/utils/app/file';
import { splitEntityId } from '@/src/utils/app/folders';
import {
getIdWithoutRootPathSegments,
getRootId,
isEntityIdExternal,
} from '@/src/utils/app/id';
import { EnumMapper } from '@/src/utils/app/mappers';
import { isEntityIdPublic } from '@/src/utils/app/publications';
import { Conversation } from '@/src/types/chat';
import { FeatureType } from '@/src/types/common';
import { DialFile } from '@/src/types/files';
import { FolderInterface } from '@/src/types/folder';
import { PublishRequestDialAIEntityModel } from '@/src/types/models';
import { SharingType } from '@/src/types/share';
import { Translation } from '@/src/types/translation';
import { ConversationsSelectors } from '@/src/store/conversations/conversations.reducers';
import { useAppDispatch, useAppSelector } from '@/src/store/hooks';
import { PromptsSelectors } from '@/src/store/prompts/prompts.reducers';
import {
PublicationActions,
PublicationSelectors,
} from '@/src/store/publication/publication.reducers';
import {
DEFAULT_VERSION,
NA_VERSION,
PUBLIC_URL_PREFIX,
} from '@/src/constants/public';
import CollapsibleSection from '@/src/components/Common/CollapsibleSection';
import {
ApplicationRow,
ConversationRow,
FilesRow,
PromptsRow,
} from '@/src/components/Common/ReplaceConfirmationModal/Components';
import { ErrorMessage } from '../../Common/ErrorMessage';
import Tooltip from '../../Common/Tooltip';
import Folder from '../../Folder/Folder';
import { PublicVersionSelector } from './PublicVersionSelector';
import {
ConversationInfo,
PublishActions,
ShareEntity,
} from '@epam/ai-dial-shared';
interface PublicationItemProps {
path: string;
children: ReactNode;
entity: ShareEntity;
type: SharingType;
publishAction: PublishActions;
parentFolderNames?: string[];
onChangeVersion: (id: string, version: string) => void;
}
function PublicationItem({
path,
children,
entity,
type,
publishAction,
parentFolderNames = [],
onChangeVersion,
}: PublicationItemProps) {
const { t } = useTranslation(Translation.Chat);
const [isVersionInvalid, setIsVersionInvalid] = useState(false);
const [version, setVersion] = useState('');
const publicVersionGroups = useAppSelector(
PublicationSelectors.selectPublicVersionGroups,
);
const handleVersionChange = (e: ChangeEvent<HTMLInputElement>) => {
const versionParts = e.target.value.split('.');
if (
versionParts.length < 4 &&
versionParts.filter(Boolean).every((part) => /^\d+$/.test(part))
) {
setVersion(e.target.value);
onChangeVersion(entity.id, e.target.value);
}
};
const constructedPublicId = constructPath(
getRootId({
featureType: EnumMapper.getFeatureTypeBySharingType(type),
bucket: PUBLIC_URL_PREFIX,
}),
path,
...parentFolderNames,
splitEntityId(entity.id).name,
);
const allVersions = useMemo(
() => publicVersionGroups[constructedPublicId]?.allVersions,
[constructedPublicId, publicVersionGroups],
);
const latestVersion = useMemo(() => {
if (allVersions) {
return findLatestVersion(allVersions.map(({ version }) => version));
}
return undefined;
}, [allVersions]);
useEffect(() => {
const versionParts = latestVersion?.split('.');
if (versionParts && isVersionValid(latestVersion)) {
versionParts[2] = String(+versionParts[2] + 1);
setVersion(versionParts.join('.'));
onChangeVersion(entity.id, versionParts.join('.'));
} else {
setVersion(DEFAULT_VERSION);
onChangeVersion(entity.id, DEFAULT_VERSION);
}
}, [entity.id, latestVersion, onChangeVersion]);
const isVersionAllowed =
!allVersions ||
!allVersions.some((versionGroup) => version === versionGroup.version);
const handleBlur = () => {
if (!isVersionValid(version)) {
setIsVersionInvalid(true);
}
};
return (
<div className="flex w-full items-center gap-2">
{children}
{publishAction !== PublishActions.DELETE ? (
<>
<PublicVersionSelector
textBeforeSelector={t('Last: ')}
publicVersionGroupId={constructPath(
getRootId({
featureType: EnumMapper.getFeatureTypeBySharingType(type),
bucket: PUBLIC_URL_PREFIX,
}),
path,
getIdWithoutRootPathSegments(entity.id),
)}
readonly
groupVersions
/>
<div className="relative">
{!isVersionAllowed ||
(isVersionInvalid && (
<Tooltip
tooltip={
!isVersionAllowed
? t('This version already exists')
: t(
'Version format is invalid (example: {{defaultVersion}})',
{
defaultVersion: DEFAULT_VERSION,
},
)
}
contentClassName="text-error text-xs"
triggerClassName="pl-0.5 absolute text-error top-1/2 -translate-y-1/2"
>
<IconAlertCircle size={14} />
</Tooltip>
))}
<input
onBlur={handleBlur}
onFocus={() => setIsVersionInvalid(false)}
value={version}
onChange={handleVersionChange}
placeholder={DEFAULT_VERSION}
className={classNames(
'm-0 h-[24px] w-[70px] border-b bg-transparent p-1 pl-[18px] text-right text-xs outline-none placeholder:text-secondary',
isVersionAllowed
? 'border-primary focus-visible:border-accent-primary'
: 'border-b-error',
isVersionInvalid && 'border-b-error',
)}
data-qa="version"
/>
</div>
</>
) : (
<span className="shrink-0 text-xs text-error" data-qa="version">
{entity.publicationInfo?.version ?? NA_VERSION}
</span>
)}
</div>
);
}
interface Props<
T extends Conversation | ShareEntity | PublishRequestDialAIEntityModel,
> {
path: string;
type: SharingType;
entity: T;
entities: T[];
files: DialFile[];
containerClassNames?: string;
publishAction: PublishActions;
onChangeVersion: (id: string, version: string) => void;
}
const getParentFolderNames = (
itemId: string,
rootFolderId: string,
folders: FolderInterface[],
) =>
folders
.filter(
(folder) =>
itemId.startsWith(`${folder.id}/`) &&
rootFolderId.length <= folder.id.length,
)
.sort((a, b) => a.id.length - b.id.length)
.map((folder) => splitEntityId(folder.id).name);
export const PublicationItemsList = memo(
<T extends Conversation | ShareEntity | PublishRequestDialAIEntityModel>({
path,
type,
entities,
entity,
files,
containerClassNames,
publishAction,
onChangeVersion,
}: Props<T>) => {
const { t } = useTranslation(Translation.Chat);
const dispatch = useAppDispatch();
const promptFolders = useAppSelector(PromptsSelectors.selectFolders);
const conversationFolders = useAppSelector(
ConversationsSelectors.selectFolders,
);
const memoizedItems = useMemo(
() => [...promptFolders, ...conversationFolders],
[conversationFolders, promptFolders],
);
const { fullyChosenFolderIds, partialChosenFolderIds } = useAppSelector(
(state) =>
PublicationSelectors.selectChosenFolderIds(
state,
memoizedItems,
entities,
),
);
const chosenItemsIds = useAppSelector(
PublicationSelectors.selectSelectedItemsToPublish,
);
useEffect(() => {
dispatch(
PublicationActions.setItemsToPublish({
ids: [
...entities.map((e) => e.id),
// TODO: remove after figuring out how to check related conversations
...(publishAction !== PublishActions.DELETE
? files.map((f) => f.id)
: []),
],
}),
);
}, [dispatch, entities, files, publishAction]);
const handleSelectItems = useCallback(
(ids: string[]) => {
dispatch(
PublicationActions.selectItemsToPublish({
ids,
}),
);
},
[dispatch],
);
const handleSelectFolder = useCallback(
(folderId: string) => {
handleSelectItems(
entities
.filter(
(e) =>
e.id.startsWith(folderId) &&
(!partialChosenFolderIds.includes(folderId) ||
!chosenItemsIds.includes(e.id)),
)
.map((e) => e.id),
);
},
[chosenItemsIds, entities, handleSelectItems, partialChosenFolderIds],
);
const additionalItemData = useMemo(
() => ({
partialSelectedFolderIds: partialChosenFolderIds,
selectedFolderIds: fullyChosenFolderIds,
}),
[fullyChosenFolderIds, partialChosenFolderIds],
);
return (
<div
className={classNames(
'flex w-full flex-col gap-[2px] overflow-y-visible md:max-w-[550px]',
containerClassNames,
)}
>
{(type === SharingType.Conversation ||
type === SharingType.ConversationFolder) && (
<>
<CollapsibleSection
togglerClassName="!text-sm !text-primary"
name={t('Conversations')}
openByDefault
className="!pl-0"
dataQa="conversations-to-send-request"
>
{type === SharingType.Conversation ? (
<PublicationItem
path={path}
type={type}
entity={entity}
onChangeVersion={onChangeVersion}
publishAction={publishAction}
>
<ConversationRow
onSelect={handleSelectItems}
itemComponentClassNames={classNames(
'w-full cursor-pointer truncate',
publishAction === PublishActions.DELETE && 'text-error',
)}
item={entity as ConversationInfo}
level={0}
isChosen={chosenItemsIds.some((id) => id === entity.id)}
/>
</PublicationItem>
) : (
<Folder
readonly
noCaretIcon
level={0}
currentFolder={entity as FolderInterface}
allFolders={conversationFolders.filter((f) =>
entities.some((item) => item.id.startsWith(`${f.id}/`)),
)}
searchTerm=""
openedFoldersIds={conversationFolders.map((f) => f.id)}
onSelectFolder={handleSelectFolder}
allItems={entities}
itemComponent={({ item, ...props }) => (
<div className="flex w-full items-center">
<PublicationItem
parentFolderNames={getParentFolderNames(
item.id,
entity.id,
conversationFolders,
)}
path={path}
type={type}
entity={item}
onChangeVersion={onChangeVersion}
publishAction={publishAction}
>
<ConversationRow
{...props}
itemComponentClassNames={classNames(
'w-full cursor-pointer truncate',
publishAction === PublishActions.DELETE &&
'text-error',
)}
item={item as ConversationInfo}
onSelect={handleSelectItems}
isChosen={chosenItemsIds.some((id) => id === item.id)}
/>
</PublicationItem>
</div>
)}
featureType={FeatureType.Chat}
folderClassName="h-[38px]"
additionalItemData={additionalItemData}
showTooltip
canSelectFolders
isSelectAlwaysVisible
/>
)}
</CollapsibleSection>
{publishAction !== PublishActions.DELETE && (
<CollapsibleSection
togglerClassName="!text-sm !text-primary"
name={t('Files')}
openByDefault
dataQa="files-to-send-request"
className="!pl-0"
>
{files.length ? (
files.map((f) => (
<div key={f.id} className="flex items-center gap-2">
<FilesRow
itemComponentClassNames={classNames(
'w-full cursor-pointer truncate',
// @ts-expect-error delete is impossible right now
publishAction === PublishActions.DELETE &&
'text-error',
)}
key={f.id}
item={f}
level={0}
onSelect={handleSelectItems}
isChosen={chosenItemsIds.some((id) => id === f.id)}
/>
<a
download={f.name}
href={constructPath('api', f.id)}
data-qa="download"
>
<IconDownload
className="shrink-0 text-secondary hover:text-accent-primary"
size={18}
/>
</a>
</div>
))
) : (
<p
className="pl-3.5 text-secondary"
data-qa="no-publishing-files"
>
{type === SharingType.Conversation ||
(type === SharingType.ConversationFolder &&
entities.length === 1)
? t("This conversation doesn't contain any files")
: t("These conversations don't contain any files")}
</p>
)}
</CollapsibleSection>
)}
</>
)}
{(type === SharingType.Prompt || type === SharingType.PromptFolder) && (
<CollapsibleSection
togglerClassName="!text-sm !text-primary"
name={t('Prompts')}
openByDefault
dataQa="prompts-to-send-request"
className="!pl-0"
>
{type === SharingType.Prompt ? (
<PublicationItem
path={path}
type={type}
entity={entity}
onChangeVersion={onChangeVersion}
publishAction={publishAction}
>
<PromptsRow
onSelect={handleSelectItems}
itemComponentClassNames={classNames(
'w-full cursor-pointer truncate',
publishAction === PublishActions.DELETE && 'text-error',
)}
item={entity}
level={0}
isChosen={chosenItemsIds.some((id) => id === entity.id)}
/>
</PublicationItem>
) : (
<Folder
readonly
noCaretIcon
level={0}
currentFolder={entity as FolderInterface}
allFolders={promptFolders.filter((f) =>
entities.some((item) => item.id.startsWith(`${f.id}/`)),
)}
searchTerm=""
openedFoldersIds={promptFolders.map((f) => f.id)}
allItems={entities}
itemComponent={({ item, ...props }) => (
<div className="flex w-full items-center">
<PublicationItem
parentFolderNames={getParentFolderNames(
item.id,
entity.id,
promptFolders,
)}
path={path}
type={type}
entity={item}
onChangeVersion={onChangeVersion}
publishAction={publishAction}
>
<PromptsRow
{...props}
item={item}
itemComponentClassNames={classNames(
'w-full cursor-pointer truncate',
publishAction === PublishActions.DELETE &&
'text-error',
)}
onSelect={handleSelectItems}
isChosen={chosenItemsIds.some((id) => id === item.id)}
/>
</PublicationItem>
</div>
)}
featureType={FeatureType.Prompt}
folderClassName="h-[38px]"
additionalItemData={additionalItemData}
showTooltip
canSelectFolders
isSelectAlwaysVisible
onSelectFolder={handleSelectFolder}
/>
)}
</CollapsibleSection>
)}
{type === SharingType.Application && (
<>
<CollapsibleSection
togglerClassName="!text-sm !text-primary"
name={t('Applications')}
openByDefault
dataQa="applications-to-send-request"
className="!pl-0"
>
<ApplicationRow
onSelect={handleSelectItems}
itemComponentClassNames={classNames(
'cursor-pointer',
publishAction === PublishActions.DELETE && 'text-error',
)}
item={entity}
level={0}
isChosen={chosenItemsIds.some((id) => id === entity.id)}
/>
</CollapsibleSection>
{publishAction === PublishActions.ADD &&
'iconUrl' in entity &&
entity.iconUrl &&
isEntityIdExternal({ id: entity.iconUrl }) && (
<CollapsibleSection
togglerClassName="!text-sm !text-primary"
name={t('Files')}
openByDefault
dataQa="files-to-send-request"
className="!pl-0"
>
<ErrorMessage
type="warning"
error={
t(
`The icon used for this app is in the ${isEntityIdPublic({ id: entity.iconUrl }) ? 'organization' : 'shared'} section and cannot be published. Please replace the icon, otherwise the app will be published with the default one.`,
) ?? ''
}
/>
</CollapsibleSection>
)}
</>
)}
</div>
);
},
);
PublicationItemsList.displayName = 'PublicationItemsList';