diff --git a/.env.github.example b/.env.github.example index 5b2aec46..adac4f2d 100644 --- a/.env.github.example +++ b/.env.github.example @@ -21,3 +21,11 @@ IL_ENABLE_DEV_MODE=false # (Optional) Enable document conversion. Any non-markdown file will be converted to markdown file # Default: false IL_ENABLE_DOC_CONVERSION=false + +# (Optional) Enable this option if you want to enable UI features that allow Skills contributions +# Default: false +IL_ENABLE_SKILLS_FEATURES=true + +# (Optional) Enable this option if you want to enable UI playground features (Chat with a model, Custom model endpoints) +# Default: false +IL_ENABLE_PLAYGROUND_FEATURES=true diff --git a/.env.native.example b/.env.native.example index b415fdd7..275ffac4 100644 --- a/.env.native.example +++ b/.env.native.example @@ -27,3 +27,11 @@ NEXT_PUBLIC_EXPERIMENTAL_FEATURES=false # (Optional) Uncomment and point to the URL the api-server is running on. Native mode only and needs # to be running on the same host as the UI. # NEXT_PUBLIC_API_SERVER=http://localhost:8080 + +# (Optional) Enable this option if you want to enable UI features that allow Skills contributions +# Default: false +IL_ENABLE_SKILLS_FEATURES=true + +# (Optional) Enable this option if you want to enable UI playground features (Chat with a model, Custom model endpoints) +# Default: false +IL_ENABLE_PLAYGROUND_FEATURES=true diff --git a/src/app/contribute/knowledge/edit/github/[...slug]/page.tsx b/src/app/contribute/knowledge/edit/github/[...slug]/page.tsx new file mode 100644 index 00000000..2ab6826c --- /dev/null +++ b/src/app/contribute/knowledge/edit/github/[...slug]/page.tsx @@ -0,0 +1,20 @@ +// src/app/contribute/knowledge/edit/github/[...slug]/page.tsx +import * as React from 'react'; +import { AppLayout } from '@/components/AppLayout'; +import EditKnowledge from '@/components/Contribute/Knowledge/Edit/github/EditKnowledge'; + +type PageProps = { + params: Promise<{ slug: string[] }>; +}; + +const EditKnowledgeGithubPage = async ({ params }: PageProps) => { + const resolvedParams = await params; + + return ( + + + + ); +}; + +export default EditKnowledgeGithubPage; diff --git a/src/app/contribute/knowledge/edit/native/[...slug]/page.tsx b/src/app/contribute/knowledge/edit/native/[...slug]/page.tsx new file mode 100644 index 00000000..e753d08a --- /dev/null +++ b/src/app/contribute/knowledge/edit/native/[...slug]/page.tsx @@ -0,0 +1,20 @@ +// src/app/contribute/knowledge/edit/native/[...slug]/page.tsx +import * as React from 'react'; +import { AppLayout } from '@/components/AppLayout'; +import EditKnowledge from '@/components/Contribute/Knowledge/Edit/native/EditKnowledge'; + +type PageProps = { + params: Promise<{ slug: string[] }>; +}; + +const EditKnowledgeNativePage = async ({ params }: PageProps) => { + const resolvedParams = await params; + + return ( + + + + ); +}; + +export default EditKnowledgeNativePage; diff --git a/src/app/contribute/knowledge/github/[...slug]/page.tsx b/src/app/contribute/knowledge/github/[...slug]/page.tsx index aea515c7..25a0e386 100644 --- a/src/app/contribute/knowledge/github/[...slug]/page.tsx +++ b/src/app/contribute/knowledge/github/[...slug]/page.tsx @@ -1,20 +1,20 @@ // src/app/contribute/knowledge/github/[...slug]/page.tsx import * as React from 'react'; import { AppLayout } from '@/components/AppLayout'; -import EditKnowledge from '@/components/Contribute/EditKnowledge/github/EditKnowledge'; +import ViewKnowledgePage from '@/components/Contribute/Knowledge/View/github/ViewKnowledgePage'; type PageProps = { params: Promise<{ slug: string[] }>; }; -const EditKnowledgePage = async ({ params }: PageProps) => { +const KnowledgeGithubPage = async ({ params }: PageProps) => { const resolvedParams = await params; return ( - + ); }; -export default EditKnowledgePage; +export default KnowledgeGithubPage; diff --git a/src/app/contribute/knowledge/native/[...slug]/page.tsx b/src/app/contribute/knowledge/native/[...slug]/page.tsx index 225d06ac..d8c01870 100644 --- a/src/app/contribute/knowledge/native/[...slug]/page.tsx +++ b/src/app/contribute/knowledge/native/[...slug]/page.tsx @@ -1,20 +1,20 @@ -// src/app/contribute/knowledge/github/[...slug]/page.tsx +// src/app/contribute/knowledge/native/[...slug]/page.tsx import * as React from 'react'; import { AppLayout } from '@/components/AppLayout'; -import EditKnowledge from '@/components/Contribute/EditKnowledge/native/EditKnowledge'; +import ViewKnowledgePage from '@/components/Contribute/Knowledge/View/native/ViewKnowledgePage'; type PageProps = { params: Promise<{ slug: string[] }>; }; -const EditKnowledgePage = async ({ params }: PageProps) => { +const KnowledgeNativePage = async ({ params }: PageProps) => { const resolvedParams = await params; return ( - + ); }; -export default EditKnowledgePage; +export default KnowledgeNativePage; diff --git a/src/app/contribute/knowledge/page.tsx b/src/app/contribute/knowledge/page.tsx index 3ac277ac..f376e3e5 100644 --- a/src/app/contribute/knowledge/page.tsx +++ b/src/app/contribute/knowledge/page.tsx @@ -4,9 +4,8 @@ import React from 'react'; import { Flex, Spinner } from '@patternfly/react-core'; import { t_global_spacer_xl as XlSpacerSize } from '@patternfly/react-tokens'; import { AppLayout } from '@/components/AppLayout'; -import KnowledgeFormGithub from '@/components/Contribute/Knowledge/Github'; -import KnowledgeFormNative from '@/components/Contribute/Knowledge/Native'; import { useEnvConfig } from '@/context/EnvConfigContext'; +import KnowledgeWizard from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard'; const KnowledgeFormPage: React.FunctionComponent = () => { const { @@ -17,7 +16,7 @@ const KnowledgeFormPage: React.FunctionComponent = () => { return ( {loaded ? ( - <>{!isGithubMode ? : } + ) : ( diff --git a/src/app/contribute/skill/edit/github/[...slug]/page.tsx b/src/app/contribute/skill/edit/github/[...slug]/page.tsx new file mode 100644 index 00000000..50c90b18 --- /dev/null +++ b/src/app/contribute/skill/edit/github/[...slug]/page.tsx @@ -0,0 +1,20 @@ +// src/app/contribute/skill/edit/github/[...slug]/page.tsx +import * as React from 'react'; +import { AppLayout } from '@/components/AppLayout'; +import EditSkill from '@/components/Contribute/Skill/Edit/github/EditSkill'; + +type PageProps = { + params: Promise<{ slug: string[] }>; +}; + +const EditSkillGithubPage = async ({ params }: PageProps) => { + const resolvedParams = await params; + + return ( + + + + ); +}; + +export default EditSkillGithubPage; diff --git a/src/app/contribute/skill/edit/native/[...slug]/page.tsx b/src/app/contribute/skill/edit/native/[...slug]/page.tsx new file mode 100644 index 00000000..461a20d1 --- /dev/null +++ b/src/app/contribute/skill/edit/native/[...slug]/page.tsx @@ -0,0 +1,20 @@ +// src/app/contribute/skill/edit/native/[...slug]/page.tsx +import * as React from 'react'; +import { AppLayout } from '@/components/AppLayout'; +import EditSkill from '@/components/Contribute/Skill/Edit/native/EditSkill'; + +type PageProps = { + params: Promise<{ slug: string[] }>; +}; + +const EditSkillNativePage = async ({ params }: PageProps) => { + const resolvedParams = await params; + + return ( + + + + ); +}; + +export default EditSkillNativePage; diff --git a/src/app/contribute/skill/github/[...slug]/page.tsx b/src/app/contribute/skill/github/[...slug]/page.tsx index c5170401..ce786308 100644 --- a/src/app/contribute/skill/github/[...slug]/page.tsx +++ b/src/app/contribute/skill/github/[...slug]/page.tsx @@ -1,20 +1,20 @@ -// src/app/contribute/knowledge/github/[...slug]/page.tsx +// src/app/contribute/skill/github/[...slug]/page.tsx import * as React from 'react'; import { AppLayout } from '@/components/AppLayout'; -import EditSkill from '@/components/Contribute/EditSkill/github/EditSkill'; +import ViewSkillPage from '@/components/Contribute/Skill/View/github/ViewSkillPage'; type PageProps = { params: Promise<{ slug: string[] }>; }; -const EditKnowledgePage = async ({ params }: PageProps) => { +const ViewSkillGithubPage = async ({ params }: PageProps) => { const resolvedParams = await params; return ( - + ); }; -export default EditKnowledgePage; +export default ViewSkillGithubPage; diff --git a/src/app/contribute/skill/native/[...slug]/page.tsx b/src/app/contribute/skill/native/[...slug]/page.tsx index a9c55be0..7ee6fb6e 100644 --- a/src/app/contribute/skill/native/[...slug]/page.tsx +++ b/src/app/contribute/skill/native/[...slug]/page.tsx @@ -1,20 +1,20 @@ -// src/app/contribute/knowledge/github/[...slug]/page.tsx +// src/app/contribute/skill/native/[...slug]/page.tsx import * as React from 'react'; import { AppLayout } from '@/components/AppLayout'; -import EditSkill from '@/components/Contribute/EditSkill/native/EditSkill'; +import ViewSkillPage from '@/components/Contribute/Skill/View/native/ViewSkillPage'; type PageProps = { params: Promise<{ slug: string[] }>; }; -const EditKnowledgePage = async ({ params }: PageProps) => { +const ViewSkillNativePage = async ({ params }: PageProps) => { const resolvedParams = await params; return ( - + ); }; -export default EditKnowledgePage; +export default ViewSkillNativePage; diff --git a/src/app/contribute/skill/page.tsx b/src/app/contribute/skill/page.tsx index ee8aab9f..27ceb8a6 100644 --- a/src/app/contribute/skill/page.tsx +++ b/src/app/contribute/skill/page.tsx @@ -4,9 +4,8 @@ import React from 'react'; import { Flex, Spinner } from '@patternfly/react-core'; import { t_global_spacer_xl as XlSpacerSize } from '@patternfly/react-tokens/dist/esm/t_global_spacer_xl'; import { AppLayout } from '@/components/AppLayout'; -import { SkillFormGithub } from '@/components/Contribute/Skill/Github/index'; -import { SkillFormNative } from '@/components/Contribute/Skill/Native/index'; import { useEnvConfig } from '@/context/EnvConfigContext'; +import SkillWizard from '@/components/Contribute/Skill/SkillWizard/SkillWizard'; const SkillFormPage: React.FunctionComponent = () => { const { @@ -17,11 +16,7 @@ const SkillFormPage: React.FunctionComponent = () => { return ( {loaded ? ( - !isGithubMode ? ( - - ) : ( - - ) + ) : ( diff --git a/src/app/dashboard/knowledge/github/[...slug]/page.tsx b/src/app/dashboard/knowledge/github/[...slug]/page.tsx deleted file mode 100644 index c7014966..00000000 --- a/src/app/dashboard/knowledge/github/[...slug]/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// src/app/dashboard/knowledge/github/[...slug]/page.tsx -import * as React from 'react'; -import { AppLayout } from '@/components/AppLayout'; -import EditKnowledge from '@/components/Contribute/EditKnowledge/github/EditKnowledge'; - -type PageProps = { - params: Promise<{ slug: string[] }>; -}; - -const EditKnowledgePage = async ({ params }: PageProps) => { - const resolvedParams = await params; - - return ( - - - - ); -}; - -export default EditKnowledgePage; diff --git a/src/app/dashboard/knowledge/native/[...slug]/page.tsx b/src/app/dashboard/knowledge/native/[...slug]/page.tsx deleted file mode 100644 index 076809ce..00000000 --- a/src/app/dashboard/knowledge/native/[...slug]/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -// src/app/dashboard/knowledge/native/[...slug]/page.tsx -import { AppLayout } from '@/components/AppLayout'; -import EditKnowledgeNative from '@/components/Contribute/EditKnowledge/native/EditKnowledge'; -import * as React from 'react'; - -type PageProps = { - params: Promise<{ slug: string[] }>; -}; - -const EditKnowledgePage = async ({ params }: PageProps) => { - const contribution = await params; - return ( - - - - ); -}; - -export default EditKnowledgePage; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index b9cf4a6c..1260b907 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,8 +6,8 @@ import { Flex, Spinner } from '@patternfly/react-core'; import { t_global_spacer_xl as XlSpacerSize } from '@patternfly/react-tokens/dist/esm/t_global_spacer_xl'; import '@patternfly/react-core/dist/styles/base.css'; import { AppLayout } from '@/components/AppLayout'; -import { DashboardGithub } from '@/components/Dashboard/Github/dashboard'; -import { DashboardNative } from '@/components/Dashboard/Native/dashboard'; +import { DashboardGithub } from '@/components/Dashboard/Github/DashboardPage'; +import { DashboardNative } from '@/components/Dashboard/Native/DashboardPage'; import { useEnvConfig } from '@/context/EnvConfigContext'; const Home: React.FunctionComponent = () => { @@ -17,7 +17,7 @@ const Home: React.FunctionComponent = () => { } = useEnvConfig(); return ( - + {loaded ? ( !isGithubMode ? ( diff --git a/src/app/dashboard/skill/github/[...slug]/page.tsx b/src/app/dashboard/skill/github/[...slug]/page.tsx deleted file mode 100644 index 78cf3a6b..00000000 --- a/src/app/dashboard/skill/github/[...slug]/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// src/app/dashboard/skill/[id]/page.tsx -import * as React from 'react'; -import { AppLayout } from '@/components/AppLayout'; -import EditSkill from '@/components/Contribute/EditSkill/github/EditSkill'; - -type PageProps = { - params: Promise<{ slug: string[] }>; -}; - -const EditSkillPage = async ({ params }: PageProps) => { - const resolvedParams = await params; - - return ( - - - - ); -}; - -export default EditSkillPage; diff --git a/src/app/dashboard/skill/native/[...slug]/page.tsx b/src/app/dashboard/skill/native/[...slug]/page.tsx deleted file mode 100644 index a52d5ff4..00000000 --- a/src/app/dashboard/skill/native/[...slug]/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -// src/app/dashboard/skill/[id]/page.tsx -import * as React from 'react'; -import { AppLayout } from '@/components/AppLayout'; -import EditSkillNative from '@/components/Contribute/EditSkill/native/EditSkill'; - -type PageProps = { - params: Promise<{ slug: string[] }>; -}; - -const EditSkillPage = async ({ params }: PageProps) => { - const contribution = await params; - - return ( - - - - ); -}; - -export default EditSkillPage; diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 831317db..dde771a4 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -41,15 +41,20 @@ interface IAppLayout { type Route = { path: string; + altPaths?: string[]; label: string; children?: Route[]; }; +const isRouteActive = (pathname: string, route: Route) => { + return pathname.startsWith(route.path) || route.altPaths?.some((altPath) => pathname.startsWith(altPath)); +}; + const AppLayout: React.FunctionComponent = ({ children, className }) => { const { data: session, status } = useSession(); const { loaded, - featureFlags: { skillFeaturesEnabled, playgroundFeaturesEnabled, experimentalFeaturesEnabled } + featureFlags: { playgroundFeaturesEnabled, experimentalFeaturesEnabled } } = useFeatureFlags(); const router = useRouter(); @@ -75,9 +80,7 @@ const AppLayout: React.FunctionComponent = ({ children, className }) } const routes = [ - { path: '/dashboard', label: 'Dashboard' }, - { path: '/contribute/knowledge', label: 'Contribute knowledge' }, - ...(skillFeaturesEnabled ? [{ path: '/contribute/skill', label: 'Contribute skills' }] : []), + { path: '/dashboard', altPaths: ['/contribute'], label: 'My contributions' }, ...(playgroundFeaturesEnabled ? [ { @@ -139,7 +142,7 @@ const AppLayout: React.FunctionComponent = ({ children, className }) ); const renderNavItem = (route: Route, index: number) => ( - + {route.label} ); @@ -148,7 +151,7 @@ const AppLayout: React.FunctionComponent = ({ children, className }) pathname.startsWith(child.path))} + isActive={isRouteActive(pathname, route) || route.children?.some((child) => isRouteActive(pathname, child))} isExpanded > {route.children?.map((child, idx) => renderNavItem(child, idx))} diff --git a/src/components/CardView/CardView.tsx b/src/components/CardView/CardView.tsx new file mode 100644 index 00000000..ffbfc336 --- /dev/null +++ b/src/components/CardView/CardView.tsx @@ -0,0 +1,125 @@ +// src/components/Dashboard/Dashboard.tsx +import * as React from 'react'; +import { Gallery, Flex, FlexItem, Toolbar, ToolbarContent, ToolbarItem, ToolbarGroup, Pagination, PaginationProps } from '@patternfly/react-core'; + +type Props = { + data: DataType[]; + cardRenderer: (data: DataType, rowIndex: number) => React.ReactNode; + enablePagination?: boolean | 'compact'; + toolbarContent?: React.ReactElement; + onClearFilters?: () => void; + emptyTableView?: React.ReactNode; + bottomToolbarContent?: React.ReactElement; + caption?: string; +} & Pick; + +const defaultPerPageOptions = [ + { + title: '10', + value: 10 + }, + { + title: '20', + value: 20 + }, + { + title: '30', + value: 30 + } +]; +const CardView = ({ + data, + cardRenderer, + enablePagination, + perPageOptions = defaultPerPageOptions, + toolbarContent, + onClearFilters, + emptyTableView +}: Props): React.ReactElement => { + const showPagination = enablePagination; + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(10); + + let viewedData: T[]; + if (enablePagination) { + viewedData = data.slice(pageSize * (page - 1), pageSize * page); + } else { + viewedData = data; + } + + // update page to 1 if data changes (common when filter is applied) + React.useEffect(() => { + if (viewedData.length === 0) { + setPage(1); + } + }, [viewedData.length]); + + const pagination = (variant: 'top' | 'bottom') => ( + setPage(newPage)} + onPerPageSelect={(_ev, newSize, newPage) => { + setPageSize(newSize); + setPage(newPage); + }} + variant={variant} + widgetId="table-pagination" + perPageOptions={perPageOptions} + menuAppendTo="inline" + titles={{ + paginationAriaLabel: `${variant} pagination` + }} + /> + ); + + const renderCards = () => data.map((card, cardIndex) => cardRenderer(card, cardIndex)); + + const gallery = ( + + {renderCards()} + + ); + + return ( + + {(toolbarContent || showPagination) && ( + + } + clearAllFilters={onClearFilters} + > + + {toolbarContent} + {showPagination && ( + + {pagination('top')} + + )} + + + + )} + 0 || !emptyTableView ? 'flex_1' : 'flexDefault' }} style={{ overflowY: 'auto' }}> + {gallery} + + {emptyTableView && data.length === 0 ? ( + +
{emptyTableView}
+
+ ) : null} + {showPagination ? {pagination('bottom')} : null} +
+ ); +}; + +export default CardView; diff --git a/src/components/Common/ViewContributionSection.tsx b/src/components/Common/ViewContributionSection.tsx new file mode 100644 index 00000000..f6b55b63 --- /dev/null +++ b/src/components/Common/ViewContributionSection.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Content, ContentVariants, DescriptionList, Flex, FlexItem } from '@patternfly/react-core'; + +interface Props { + title: React.ReactNode; + descriptionText?: React.ReactNode; + descriptionItems: React.ReactNode[]; +} + +const ViewContributionSection: React.FC = ({ title, descriptionText, descriptionItems }) => ( + + + + + {title} + + {descriptionText ? ( + + + {descriptionText} + + + ) : null} + + + + {descriptionItems} + + +); + +export default ViewContributionSection; diff --git a/src/components/Common/XsExternalLinkAltIcon.tsx b/src/components/Common/XsExternalLinkAltIcon.tsx new file mode 100644 index 00000000..6d9d3b8e --- /dev/null +++ b/src/components/Common/XsExternalLinkAltIcon.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; +import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import { t_global_font_size_xs as FontSizeXs } from '@patternfly/react-tokens'; + +const XsExternalLinkAltIcon: React.FC = () => ; + +export default XsExternalLinkAltIcon; diff --git a/src/components/Contribute/AutoFill.ts b/src/components/Contribute/AutoFill.ts index 66388d6f..4c314900 100644 --- a/src/components/Contribute/AutoFill.ts +++ b/src/components/Contribute/AutoFill.ts @@ -250,7 +250,7 @@ const knowledgeSeedExamples: KnowledgeSeedExample[] = [ } ]; -export const autoFillKnowledgeFields: KnowledgeFormData = { +export const getAutoFillKnowledgeFields = (): KnowledgeFormData => ({ branchName: `knowledge-contribution-${Date.now()}`, email: 'helloworld@instructlab.com', name: 'juliadenham', @@ -267,7 +267,7 @@ export const autoFillKnowledgeFields: KnowledgeFormData = { creators: 'Wikipedia Authors', filesToUpload: [], uploadedFiles: [] -}; +}); const skillsSeedExamples: SkillSeedExample[] = [ { @@ -352,7 +352,7 @@ const skillsSeedExamples: SkillSeedExample[] = [ } ]; -export const autoFillSkillsFields: SkillFormData = { +export const getAutoFillSkillsFields = (): SkillFormData => ({ branchName: `skill-contribution-${Date.now()}`, email: 'helloworld@instructlab.com', name: 'juliadenham', @@ -362,4 +362,4 @@ export const autoFillSkillsFields: SkillFormData = { titleWork: 'Teaching a model to rhyme.', licenseWork: 'CC-BY-SA-4.0', creators: 'juliadenham' -}; +}); diff --git a/src/components/Contribute/ClearDraftDataButton.tsx b/src/components/Contribute/ClearDraftDataButton.tsx new file mode 100644 index 00000000..2854979a --- /dev/null +++ b/src/components/Contribute/ClearDraftDataButton.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import { Button, Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant } from '@patternfly/react-core'; +import { t_global_spacer_md as MdSpacer } from '@patternfly/react-tokens'; +import { clearAllDraftData } from '@/components/Contribute/Utils/autoSaveUtils'; + +interface Props { + onCleared: () => void; +} +const ClearDraftDataButton: React.FC = ({ onCleared }) => { + const [isConfirmOpen, setIsConfirmOpen] = React.useState(false); + + const handleClearDraftData = () => { + clearAllDraftData(); + setIsConfirmOpen(false); + onCleared(); + }; + + return ( + + + {isConfirmOpen ? ( + setIsConfirmOpen(false)} aria-labelledby="clear-drafts-modal-title"> + + +

Are you sure you want to clear all draft data?

+
+ + + + +
+ ) : null} +
+ ); +}; + +export default ClearDraftDataButton; diff --git a/src/components/Contribute/ContributeAlertGroup.tsx b/src/components/Contribute/ContributeAlertGroup.tsx index 9e8bd472..364f96fb 100644 --- a/src/components/Contribute/ContributeAlertGroup.tsx +++ b/src/components/Contribute/ContributeAlertGroup.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { AlertGroup, Alert, AlertActionCloseButton, Spinner, Button, Flex, FlexItem } from '@patternfly/react-core'; import { ActionGroupAlertContent } from '@/components/Contribute/types'; -import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; export interface ContributeAlertGroupProps { actionGroupAlertContent?: ActionGroupAlertContent; @@ -48,7 +48,7 @@ export const ContributeAlertGroup: React.FunctionComponent : undefined} + icon={actionGroupAlertContent.isUrlExternal ? : undefined} iconPosition="end" > {actionGroupAlertContent.urlText || 'View your new branch'} diff --git a/src/components/Contribute/ContributionLabels.scss b/src/components/Contribute/ContributionLabels.scss new file mode 100644 index 00000000..862a4e3a --- /dev/null +++ b/src/components/Contribute/ContributionLabels.scss @@ -0,0 +1,14 @@ +.pf-v6-c-label { + &.knowledge-contribution-label { + --pf-v6-c-label--m-outline--BorderColor: var(--pf-t--global--color--status--custom--default); + } + &.skill-contribution-label { + --pf-v6-c-label--m-outline--BorderColor: var(--pf-t--global--border--color--nonstatus--orange--default); + } + &.new-contribution-label { + --pf-v6-c-label--Color: var(--pf-t--color--black); + --pf-v6-c-label--BackgroundColor: var(--pf-t--global--color--nonstatus--blue--default); + --pf-v6-c-label--m-outline--Color: var(--pf-t--color--black); + --pf-v6-c-label--m-outline--BackgroundColor: var(--pf-t--global--color--nonstatus--blue--default); + } +} diff --git a/src/components/Contribute/ContributionLabels.tsx b/src/components/Contribute/ContributionLabels.tsx new file mode 100644 index 00000000..18ec1ea8 --- /dev/null +++ b/src/components/Contribute/ContributionLabels.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Label } from '@patternfly/react-core'; +import { CatalogIcon } from '@patternfly/react-icons'; + +import './ContributionLabels.scss'; + +const KnowledgeContributionLabel: React.FC = () => ( + +); + +import { TaskIcon } from '@patternfly/react-icons'; + +const SkillContributionLabel: React.FC = () => ( + +); + +const NewContributionLabel: React.FC = () => ( + +); + +export { KnowledgeContributionLabel, SkillContributionLabel, NewContributionLabel }; diff --git a/src/components/Contribute/AttributionInformation/AttributionInformation.tsx b/src/components/Contribute/ContributionWizard/AttributionInformation/AttributionInformation.tsx similarity index 100% rename from src/components/Contribute/AttributionInformation/AttributionInformation.tsx rename to src/components/Contribute/ContributionWizard/AttributionInformation/AttributionInformation.tsx diff --git a/src/components/Contribute/ContributionWizard/ContributionWizard.tsx b/src/components/Contribute/ContributionWizard/ContributionWizard.tsx index c5c515fe..5516e045 100644 --- a/src/components/Contribute/ContributionWizard/ContributionWizard.tsx +++ b/src/components/Contribute/ContributionWizard/ContributionWizard.tsx @@ -5,7 +5,7 @@ import { useSession } from 'next-auth/react'; import { Button, Content, Flex, FlexItem, PageGroup, PageSection, Title, Wizard, WizardStep } from '@patternfly/react-core'; import { ContributionFormData, EditFormData } from '@/types'; import { useRouter } from 'next/navigation'; -import { autoFillKnowledgeFields, autoFillSkillsFields } from '@/components/Contribute/AutoFill'; +import { getAutoFillKnowledgeFields, getAutoFillSkillsFields } from '@/components/Contribute/AutoFill'; import { getGitHubUserInfo } from '@/utils/github'; import ContributionWizardFooter from '@/components/Contribute/ContributionWizard/ContributionWizardFooter'; import { deleteDraftData } from '@/components/Contribute/Utils/autoSaveUtils'; @@ -124,7 +124,7 @@ export const ContributionWizard: React.FunctionComponent = ({ }, [isGithubMode, session?.accessToken, session?.user?.name, session?.user?.email, setFormData]); const autoFillForm = (): void => { - setFormData(isSkillContribution ? { ...autoFillSkillsFields } : { ...autoFillKnowledgeFields }); + setFormData(isSkillContribution ? getAutoFillSkillsFields() : getAutoFillKnowledgeFields()); }; const handleCancel = () => { diff --git a/src/components/Contribute/DetailsPage/DetailsPage.tsx b/src/components/Contribute/ContributionWizard/DetailsPage/DetailsPage.tsx similarity index 98% rename from src/components/Contribute/DetailsPage/DetailsPage.tsx rename to src/components/Contribute/ContributionWizard/DetailsPage/DetailsPage.tsx index e429c2f5..f1d28acf 100644 --- a/src/components/Contribute/DetailsPage/DetailsPage.tsx +++ b/src/components/Contribute/ContributionWizard/DetailsPage/DetailsPage.tsx @@ -22,7 +22,7 @@ import PathService from '@/components/PathService/PathService'; import WizardPageHeader from '@/components/Common/WizardPageHeader'; import WizardSectionHeader from '@/components/Common/WizardSectionHeader'; import { MAX_SUMMARY_CHARS } from '@/components/Contribute/Utils/validationUtils'; -import EditContributorModal from '@/components/Contribute/DetailsPage/EditContributorModal'; +import EditContributorModal from '@/components/Contribute/ContributionWizard/DetailsPage/EditContributorModal'; interface Props { isGithubMode: boolean; diff --git a/src/components/Contribute/DetailsPage/EditContributorModal.tsx b/src/components/Contribute/ContributionWizard/DetailsPage/EditContributorModal.tsx similarity index 97% rename from src/components/Contribute/DetailsPage/EditContributorModal.tsx rename to src/components/Contribute/ContributionWizard/DetailsPage/EditContributorModal.tsx index ce1c3b75..0202c417 100644 --- a/src/components/Contribute/DetailsPage/EditContributorModal.tsx +++ b/src/components/Contribute/ContributionWizard/DetailsPage/EditContributorModal.tsx @@ -18,7 +18,7 @@ import { TextInput, ValidatedOptions } from '@patternfly/react-core'; -import { ExternalLinkAltIcon } from '@patternfly/react-icons'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; const validateName = (newName: string): ValidatedOptions => { return newName.trim().length > 0 ? ValidatedOptions.success : ValidatedOptions.error; @@ -80,7 +80,7 @@ export const EditContributorModal: React.FunctionComponent = ({ name = '' href="https://docs.instructlab.ai/community/CONTRIBUTING/#developer-certificate-of-origin-dco" target="_blank" rel="noopener noreferrer" - icon={} + icon={} iconPosition="end" > Learn more about GitHub Developer Certificate of Origin (DCO) sign-off. diff --git a/src/components/Contribute/ReviewSubmission/ReviewSection.tsx b/src/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSection.tsx similarity index 100% rename from src/components/Contribute/ReviewSubmission/ReviewSection.tsx rename to src/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSection.tsx diff --git a/src/components/Contribute/ReviewSubmission/ReviewSubmission.tsx b/src/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSubmission.tsx similarity index 98% rename from src/components/Contribute/ReviewSubmission/ReviewSubmission.tsx rename to src/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSubmission.tsx index 53d7afac..c8f9cb88 100644 --- a/src/components/Contribute/ReviewSubmission/ReviewSubmission.tsx +++ b/src/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSubmission.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { ContributionFormData, KnowledgeFormData, KnowledgeSeedExample, SkillFormData, SkillSeedExample } from '@/types'; import { Flex, DescriptionListGroup, DescriptionListTerm, DescriptionListDescription, FlexItem } from '@patternfly/react-core'; import WizardPageHeader from '@/components/Common/WizardPageHeader'; -import ReviewSection from '@/components/Contribute/ReviewSubmission/ReviewSection'; +import ReviewSection from '@/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSection'; import { getValidatedKnowledgeSeedExamples, getValidatedSkillSeedExamples } from '@/components/Contribute/Utils/validationUtils'; interface ReviewSubmissionProps { diff --git a/src/components/Contribute/EditKnowledge/github/EditKnowledge.tsx b/src/components/Contribute/EditKnowledge/github/EditKnowledge.tsx deleted file mode 100644 index b688fb7a..00000000 --- a/src/components/Contribute/EditKnowledge/github/EditKnowledge.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// src/app/components/contribute/EditKnowledge/github/EditKnowledge.tsx -'use client'; - -import * as React from 'react'; -import { useSession } from 'next-auth/react'; -import { AttributionData, PullRequestFile, KnowledgeYamlData, KnowledgeSeedExample } from '@/types'; -import { KnowledgeSchemaVersion } from '@/types/const'; -import { fetchPullRequest, fetchFileContent, fetchPullRequestFiles } from '@/utils/github'; -import yaml from 'js-yaml'; -import axios from 'axios'; -import { KnowledgeEditFormData, KnowledgeFormData, QuestionAndAnswerPair } from '@/types'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import KnowledgeFormGithub from '../../Knowledge/Github'; -import { ValidatedOptions, Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; -import { fetchExistingKnowledgeDocuments } from '@/components/Contribute/Utils/documentUtils'; -import { fetchDraftKnowledgeChanges } from '@/components/Contribute/Utils/autoSaveUtils'; - -interface EditKnowledgeClientComponentProps { - prNumber: string; - isDraft: boolean; -} - -const EditKnowledge: React.FC = ({ prNumber, isDraft }) => { - const { data: session } = useSession(); - const [isLoading, setIsLoading] = useState(true); - const [loadingMsg, setLoadingMsg] = useState(''); - const [knowledgeEditFormData, setKnowledgeEditFormData] = useState(); - const router = useRouter(); - - useEffect(() => { - if (isDraft) { - fetchDraftKnowledgeChanges({ branchName: prNumber, setIsLoading, setLoadingMsg, setKnowledgeEditFormData }); - return; - } - - setLoadingMsg('Fetching knowledge data from PR : ' + prNumber); - const fetchPRData = async () => { - if (session?.accessToken) { - try { - const prNum = parseInt(prNumber, 10); - const prData = await fetchPullRequest(session.accessToken, prNum); - - // Create KnowledgeFormData from existing form. - const knowledgeExistingFormData: KnowledgeFormData = { - branchName: '', - email: '', - name: '', - submissionSummary: '', - filePath: '', - seedExamples: [], - knowledgeDocumentRepositoryUrl: '', - knowledgeDocumentCommit: '', - documentName: '', - titleWork: '', - linkWork: '', - revision: '', - licenseWork: '', - creators: '', - filesToUpload: [], - uploadedFiles: [] - }; - - const knowledgeEditFormData: KnowledgeEditFormData = { - isEditForm: true, - isSubmitted: true, - version: KnowledgeSchemaVersion, - formData: knowledgeExistingFormData, - pullRequestNumber: prNum, - oldFilesPath: '' - }; - - knowledgeExistingFormData.submissionSummary = prData.title; - knowledgeExistingFormData.branchName = prData.head.ref; // Store the branch name from the pull request - - const prFiles: PullRequestFile[] = await fetchPullRequestFiles(session.accessToken, prNum); - - const foundYamlFile = prFiles.find((file: PullRequestFile) => file.filename.endsWith('.yaml')); - if (!foundYamlFile) { - throw new Error('No YAML file found in the pull request.'); - } - const existingFilesPath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); - - // Set the current Yaml file path as a old files path - knowledgeEditFormData.oldFilesPath = existingFilesPath + '/'; - - const yamlContent = await fetchFileContent(session.accessToken, foundYamlFile.filename, prData.head.sha); - const yamlData: KnowledgeYamlData = yaml.load(yamlContent) as KnowledgeYamlData; - console.log('Parsed Knowledge YAML data:', yamlData); - - // Populate the form fields with YAML data - knowledgeExistingFormData.knowledgeDocumentRepositoryUrl = yamlData.document.repo; - knowledgeExistingFormData.knowledgeDocumentCommit = yamlData.document.commit; - knowledgeExistingFormData.documentName = yamlData.document.patterns.join(', '); - - const seedExamples: KnowledgeSeedExample[] = []; - yamlData.seed_examples.forEach((seed, index) => { - // iterate through KnowledgeSeedExample and create a new object for each - const example: KnowledgeSeedExample = { - immutable: index < 5 ? true : false, - isExpanded: true, - context: seed.context, - isContextValid: ValidatedOptions.success, - questionAndAnswers: [] - }; - - const qnaExamples: QuestionAndAnswerPair[] = seed.questions_and_answers.map((qa, index) => { - const qna: QuestionAndAnswerPair = { - question: qa.question, - answer: qa.answer, - immutable: index < 3 ? true : false, - isQuestionValid: ValidatedOptions.success, - isAnswerValid: ValidatedOptions.success - }; - return qna; - }); - example.questionAndAnswers = qnaExamples; - seedExamples.push(example); - }); - knowledgeExistingFormData.seedExamples = seedExamples; - - // Set the file path from the current YAML file (remove the root folder name from the path) - const currentFilePath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); - knowledgeEditFormData.formData.filePath = currentFilePath + '/'; - - // Fetch and parse attribution file if it exists - const foundAttributionFile = prFiles.find((file: PullRequestFile) => file.filename.includes('attribution')); - if (foundAttributionFile) { - const attributionContent = await fetchFileContent(session.accessToken, foundAttributionFile.filename, prData.head.sha); - const attributionData = parseAttributionContent(attributionContent); - console.log('Parsed knowledge attribution data:', attributionData); - - // Populate the form fields with attribution data - knowledgeExistingFormData.titleWork = attributionData.title_of_work; - knowledgeExistingFormData.linkWork = attributionData.link_to_work ? attributionData.link_to_work : ''; - knowledgeExistingFormData.revision = attributionData.revision ? attributionData.revision : ''; - knowledgeExistingFormData.licenseWork = attributionData.license_of_the_work; - knowledgeExistingFormData.creators = attributionData.creator_names; - } - setKnowledgeEditFormData(knowledgeEditFormData); - const existingFiles = await fetchExistingKnowledgeDocuments(true, knowledgeEditFormData.formData.knowledgeDocumentCommit); - if (existingFiles.length != 0) { - console.log(`Contribution has ${existingFiles.length} existing knowledge documents`); - knowledgeExistingFormData.uploadedFiles.push(...existingFiles); - } - setIsLoading(false); - } catch (error) { - if (axios.isAxiosError(error)) { - console.error('Error fetching pull request data:', error.response ? error.response.data : error.message); - setLoadingMsg('Error fetching knowledge data from PR : ' + prNumber + '. Please try again.'); - } else if (error instanceof Error) { - console.error('Error fetching pull request data:', error.message); - setLoadingMsg('Error fetching knowledge data from PR : ' + prNumber + ' [' + error.message + ']' + '. Please try again.'); - } - } - } - }; - fetchPRData(); - }, [session?.accessToken, prNumber, isDraft]); - - const parseAttributionContent = (content: string): AttributionData => { - const lines = content.split('\n'); - const attributionData: { [key: string]: string } = {}; - lines.forEach((line) => { - const [key, ...value] = line.split(':'); - if (key && value) { - // Remove spaces in the attribution field for parsing - const normalizedKey = key.trim().toLowerCase().replace(/\s+/g, '_'); - attributionData[normalizedKey] = value.join(':').trim(); - } - }); - return attributionData as unknown as AttributionData; - }; - - const handleOnClose = () => { - router.push('/dashboard'); - setIsLoading(false); - }; - - if (isLoading) { - return ( - handleOnClose()}> - -
{loadingMsg}
-
-
- ); - } - - return ; -}; - -export default EditKnowledge; diff --git a/src/components/Contribute/EditKnowledge/native/EditKnowledge.tsx b/src/components/Contribute/EditKnowledge/native/EditKnowledge.tsx deleted file mode 100644 index 3f250b8a..00000000 --- a/src/components/Contribute/EditKnowledge/native/EditKnowledge.tsx +++ /dev/null @@ -1,198 +0,0 @@ -// src/app/components/contribute/EditKnowledge/native/EditKnowledge.tsx -'use client'; - -import * as React from 'react'; -import { useSession } from 'next-auth/react'; -import { AttributionData, KnowledgeSeedExample, KnowledgeYamlData } from '@/types'; -import { KnowledgeSchemaVersion } from '@/types/const'; -import yaml from 'js-yaml'; -import { KnowledgeEditFormData, KnowledgeFormData, QuestionAndAnswerPair } from '@/types'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { ValidatedOptions, Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; -import KnowledgeFormNative from '../../Knowledge/Native'; -import { fetchExistingKnowledgeDocuments } from '@/components/Contribute/Utils/documentUtils'; -import { fetchDraftKnowledgeChanges } from '@/components/Contribute/Utils/autoSaveUtils'; - -interface ChangeData { - file: string; - status: string; - content?: string; - commitSha?: string; -} - -interface EditKnowledgeClientComponentProps { - branchName: string; - isDraft: boolean; -} - -const EditKnowledgeNative: React.FC = ({ branchName, isDraft }) => { - const { data: session } = useSession(); - const [isLoading, setIsLoading] = useState(true); - const [loadingMsg, setLoadingMsg] = useState(''); - const [knowledgeEditFormData, setKnowledgeEditFormData] = useState(); - const router = useRouter(); - - useEffect(() => { - if (isDraft) { - fetchDraftKnowledgeChanges({ branchName, setIsLoading, setLoadingMsg, setKnowledgeEditFormData }); - return; - } - - setLoadingMsg('Fetching knowledge data from branch : ' + branchName); - const fetchBranchChanges = async () => { - try { - const response = await fetch('/api/native/git/branches', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ branchName, action: 'diff' }) - }); - - const result = await response.json(); - if (response.ok) { - // Create KnowledgeFormData from existing form. - const knowledgeExistingFormData: KnowledgeFormData = { - branchName: branchName, - email: '', - name: '', - submissionSummary: '', - filePath: '', - seedExamples: [], - knowledgeDocumentRepositoryUrl: '', - knowledgeDocumentCommit: '', - documentName: '', - titleWork: '', - linkWork: '', - revision: '', - licenseWork: '', - creators: '', - filesToUpload: [], - uploadedFiles: [] - }; - - const knowledgeEditFormData: KnowledgeEditFormData = { - isEditForm: true, - isSubmitted: true, - version: KnowledgeSchemaVersion, - formData: knowledgeExistingFormData, - pullRequestNumber: 0, - oldFilesPath: '' - }; - - if (session?.user?.name && session?.user?.email) { - knowledgeExistingFormData.name = session?.user?.name; - knowledgeExistingFormData.email = session?.user?.email; - } - - if (result?.commitDetails != null) { - knowledgeExistingFormData.submissionSummary = result?.commitDetails.message; - knowledgeExistingFormData.name = result?.commitDetails.name; - knowledgeExistingFormData.email = result?.commitDetails.email; - } - - if (result?.changes.length > 0) { - result.changes.forEach((change: ChangeData) => { - if (change.status != 'deleted' && change.content) { - if (change.file.includes('qna.yaml')) { - const yamlData: KnowledgeYamlData = yaml.load(change.content) as KnowledgeYamlData; - console.log('Parsed Knowledge YAML data:', yamlData); - // Populate the form fields with YAML data - knowledgeExistingFormData.knowledgeDocumentRepositoryUrl = yamlData.document.repo; - knowledgeExistingFormData.knowledgeDocumentCommit = yamlData.document.commit; - knowledgeExistingFormData.documentName = yamlData.document.patterns.join(', '); - - const seedExamples: KnowledgeSeedExample[] = []; - yamlData.seed_examples.forEach((seed, index) => { - // iterate through questions_and_answers and create a new object for each - const example: KnowledgeSeedExample = { - immutable: index < 5 ? true : false, - isExpanded: true, - context: seed.context, - isContextValid: ValidatedOptions.success, - questionAndAnswers: [] - }; - - const qnaExamples: QuestionAndAnswerPair[] = seed.questions_and_answers.map((qa, index) => { - const qna: QuestionAndAnswerPair = { - question: qa.question, - answer: qa.answer, - immutable: index < 3 ? true : false, - isQuestionValid: ValidatedOptions.success, - isAnswerValid: ValidatedOptions.success - }; - return qna; - }); - example.questionAndAnswers = qnaExamples; - seedExamples.push(example); - }); - - knowledgeExistingFormData.seedExamples = seedExamples; - // Set the file path from the current YAML file (remove the root folder name from the path) - const currentFilePath = change.file.split('/').slice(1, -1).join('/'); - knowledgeExistingFormData.filePath = currentFilePath + '/'; - - // Set the oldFilesPath to the existing qna.yaml file path. - knowledgeEditFormData.oldFilesPath = knowledgeExistingFormData.filePath; - } - if (change.file.includes('attribution.txt')) { - const attributionData = parseAttributionContent(change.content); - console.log('Parsed knowledge attribution data:', attributionData); - - // Populate the form fields with attribution data - knowledgeExistingFormData.titleWork = attributionData.title_of_work; - knowledgeExistingFormData.linkWork = attributionData.link_to_work ? attributionData.link_to_work : ''; - knowledgeExistingFormData.revision = attributionData.revision ? attributionData.revision : ''; - knowledgeExistingFormData.licenseWork = attributionData.license_of_the_work; - knowledgeExistingFormData.creators = attributionData.creator_names; - } - } - }); - setKnowledgeEditFormData(knowledgeEditFormData); - const existingFiles = await fetchExistingKnowledgeDocuments(false, knowledgeEditFormData.formData.knowledgeDocumentCommit); - if (existingFiles.length != 0) { - console.log(`Contribution has ${existingFiles.length} existing knowledge documents`); - knowledgeExistingFormData.uploadedFiles.push(...existingFiles); - } - setIsLoading(false); - } - } - } catch (error) { - console.error('Error fetching branch changes:', error); - } - }; - fetchBranchChanges(); - }, [branchName, isDraft, session?.user?.email, session?.user?.name]); - - const parseAttributionContent = (content: string): AttributionData => { - const lines = content.split('\n'); - const attributionData: { [key: string]: string } = {}; - lines.forEach((line) => { - const [key, ...value] = line.split(':'); - if (key && value) { - // Remove spaces in the attribution field for parsing - const normalizedKey = key.trim().toLowerCase().replace(/\s+/g, '_'); - attributionData[normalizedKey] = value.join(':').trim(); - } - }); - return attributionData as unknown as AttributionData; - }; - - const handleOnClose = () => { - router.push('/dashboard'); - setIsLoading(false); - }; - - if (isLoading) { - return ( - handleOnClose()}> - -
{loadingMsg}
-
-
- ); - } - - return ; -}; - -export default EditKnowledgeNative; diff --git a/src/components/Contribute/EditSkill/github/EditSkill.tsx b/src/components/Contribute/EditSkill/github/EditSkill.tsx deleted file mode 100644 index a4364a3a..00000000 --- a/src/components/Contribute/EditSkill/github/EditSkill.tsx +++ /dev/null @@ -1,166 +0,0 @@ -// src/app/components/contribute/EditSkill/github/EditSkill.tsx -'use client'; - -import * as React from 'react'; -import { useSession } from 'next-auth/react'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import SkillFormGithub from '@/components/Contribute/Skill/Github'; -import { fetchPullRequest, fetchFileContent, fetchPullRequestFiles } from '@/utils/github'; -import yaml from 'js-yaml'; -import axios from 'axios'; -import { SkillYamlData, AttributionData, PullRequestFile, SkillFormData, SkillEditFormData, SkillSeedExample } from '@/types'; -import { SkillSchemaVersion } from '@/types/const'; -import { ValidatedOptions, Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; -import { fetchDraftSkillChanges } from '@/components/Contribute/Utils/autoSaveUtils'; - -interface EditSkillClientComponentProps { - prNumber: string; - isDraft: boolean; -} - -const EditSkill: React.FC = ({ prNumber, isDraft }) => { - const { data: session } = useSession(); - const [isLoading, setIsLoading] = useState(true); - const [loadingMsg, setLoadingMsg] = useState(''); - const [skillEditFormData, setSkillEditFormData] = useState(); - const router = useRouter(); - - useEffect(() => { - if (isDraft) { - fetchDraftSkillChanges({ branchName: prNumber, setIsLoading, setLoadingMsg, setSkillEditFormData }); - return; - } - - const fetchPRData = async () => { - setLoadingMsg('Fetching skill data from PR: ' + prNumber); - if (session?.accessToken) { - try { - const prNum = parseInt(prNumber, 10); - const prData = await fetchPullRequest(session.accessToken, prNum); - - const skillExistingFormData: SkillFormData = { - branchName: '', - email: '', - name: '', - submissionSummary: '', - filePath: '', - seedExamples: [], - titleWork: '', - licenseWork: '', - creators: '' - }; - - const skillEditFormData: SkillEditFormData = { - isEditForm: true, - isSubmitted: true, - version: SkillSchemaVersion, - formData: skillExistingFormData, - pullRequestNumber: prNum, - oldFilesPath: '' - }; - - skillExistingFormData.branchName = prData.head.ref; // Store the branch name from the pull request - - const prFiles: PullRequestFile[] = await fetchPullRequestFiles(session.accessToken, prNum); - - const foundYamlFile = prFiles.find((file: PullRequestFile) => file.filename.endsWith('.yaml')); - if (!foundYamlFile) { - throw new Error('No YAML file found in the pull request.'); - } - - const existingFilesPath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); - - // Set the current Yaml file path as a old files path - skillEditFormData.oldFilesPath = existingFilesPath + '/'; - - const yamlContent = await fetchFileContent(session.accessToken, foundYamlFile.filename, prData.head.sha); - const yamlData: SkillYamlData = yaml.load(yamlContent) as SkillYamlData; - console.log('Parsed YAML data:', yamlData); - - // Populate the form fields with YAML data - skillExistingFormData.submissionSummary = yamlData.task_description; - - const seedExamples: SkillSeedExample[] = []; - yamlData.seed_examples.forEach((seed, index) => { - const example: SkillSeedExample = { - immutable: index < 5 ? true : false, - isExpanded: true, - context: seed.context, - isContextValid: ValidatedOptions.success, - questionAndAnswer: { - immutable: index < 5 ? true : false, - question: seed.question, - isQuestionValid: ValidatedOptions.success, - answer: seed.answer, - isAnswerValid: ValidatedOptions.success - } - }; - seedExamples.push(example); - }); - skillExistingFormData.seedExamples = seedExamples; - - // Set the file path from the current YAML file - const currentFilePath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); - skillEditFormData.formData.filePath = currentFilePath + '/'; - - // Fetch and parse attribution file if it exists - const foundAttributionFile = prFiles.find((file: PullRequestFile) => file.filename.includes('attribution')); - if (foundAttributionFile) { - const attributionContent = await fetchFileContent(session.accessToken, foundAttributionFile.filename, prData.head.sha); - const attributionData = parseAttributionContent(attributionContent); - console.log('Parsed attribution data:', attributionData); - - // Populate the form fields with attribution data - skillExistingFormData.titleWork = attributionData.title_of_work; - skillExistingFormData.licenseWork = attributionData.license_of_the_work; - skillExistingFormData.creators = attributionData.creator_names; - } - setSkillEditFormData(skillEditFormData); - setIsLoading(false); - } catch (error) { - if (axios.isAxiosError(error)) { - console.error('Error fetching pull request data:', error.response ? error.response.data : error.message); - setLoadingMsg('Error fetching skills data from PR: ' + prNumber + '. Please try again.'); - } else if (error instanceof Error) { - console.error('Error fetching pull request data:', error.message); - setLoadingMsg('Error fetching skills data from PR: ' + prNumber + ' [' + error.message + ']. Please try again.'); - } - } - } - }; - fetchPRData(); - }, [session?.accessToken, prNumber, isDraft]); - - const parseAttributionContent = (content: string): AttributionData => { - const lines = content.split('\n'); - const attributionData: { [key: string]: string } = {}; - lines.forEach((line) => { - const [key, ...value] = line.split(':'); - if (key && value) { - const normalizedKey = key.trim().toLowerCase().replace(/\s+/g, '_'); - attributionData[normalizedKey] = value.join(':').trim(); - } - }); - return attributionData as unknown as AttributionData; - }; - - const handleOnClose = () => { - router.push('/dashboard'); - setIsLoading(false); - }; - - if (isLoading) { - return ( - - -
{loadingMsg}
-
-
- ); - } - - return ; -}; - -export default EditSkill; diff --git a/src/components/Contribute/EditSkill/native/EditSkill.tsx b/src/components/Contribute/EditSkill/native/EditSkill.tsx deleted file mode 100644 index 844bec12..00000000 --- a/src/components/Contribute/EditSkill/native/EditSkill.tsx +++ /dev/null @@ -1,170 +0,0 @@ -// src/app/components/contribute/EditSkill/native/EditSkill.tsx -'use client'; - -import * as React from 'react'; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import yaml from 'js-yaml'; -import { SkillYamlData, AttributionData, SkillFormData, SkillEditFormData, SkillSeedExample } from '@/types'; -import { SkillSchemaVersion } from '@/types/const'; -import { ValidatedOptions, Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; -import SkillFormNative from '../../Skill/Native'; -import { useSession } from 'next-auth/react'; -import { fetchDraftSkillChanges } from '@/components/Contribute/Utils/autoSaveUtils'; - -interface ChangeData { - file: string; - status: string; - content?: string; - commitSha?: string; -} - -interface EditSkillClientComponentProps { - branchName: string; - isDraft: boolean; -} - -const EditSkillNative: React.FC = ({ branchName, isDraft }) => { - const { data: session } = useSession(); - const [isLoading, setIsLoading] = useState(true); - const [loadingMsg, setLoadingMsg] = useState(''); - const [skillEditFormData, setSkillEditFormData] = useState(); - const router = useRouter(); - - useEffect(() => { - if (isDraft) { - fetchDraftSkillChanges({ branchName, setIsLoading, setLoadingMsg, setSkillEditFormData }); - return; - } - - const fetchBranchChanges = async () => { - setLoadingMsg('Fetching skill data from branch: ' + branchName); - try { - const response = await fetch('/api/native/git/branches', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ branchName, action: 'diff' }) - }); - - const result = await response.json(); - if (response.ok) { - const skillExistingFormData: SkillFormData = { - branchName: branchName, - email: '', - name: '', - submissionSummary: '', - filePath: '', - seedExamples: [], - titleWork: '', - licenseWork: '', - creators: '' - }; - - const skillEditFormData: SkillEditFormData = { - isEditForm: true, - isSubmitted: true, - version: SkillSchemaVersion, - pullRequestNumber: 0, - formData: skillExistingFormData, - oldFilesPath: '' - }; - - if (session?.user?.name && session?.user?.email) { - skillExistingFormData.name = session?.user?.name; - skillExistingFormData.email = session?.user?.email; - } - - if (result?.commitDetails != null) { - skillExistingFormData.submissionSummary = result?.commitDetails.message; - skillExistingFormData.name = result?.commitDetails.name; - skillExistingFormData.email = result?.commitDetails.email; - } - - if (result?.changes.length > 0) { - result.changes.forEach((change: ChangeData) => { - if (change.status != 'deleted' && change.content) { - if (change.file.includes('qna.yaml')) { - const yamlData: SkillYamlData = yaml.load(change.content) as SkillYamlData; - console.log('Parsed skill YAML data:', yamlData); - skillExistingFormData.submissionSummary = yamlData.task_description; - const seedExamples: SkillSeedExample[] = []; - yamlData.seed_examples.forEach((seed, index) => { - const example: SkillSeedExample = { - immutable: index < 5 ? true : false, - isExpanded: true, - context: seed.context, - isContextValid: ValidatedOptions.success, - questionAndAnswer: { - immutable: index < 5 ? true : false, - question: seed.question, - isQuestionValid: ValidatedOptions.success, - answer: seed.answer, - isAnswerValid: ValidatedOptions.success - } - }; - seedExamples.push(example); - }); - skillExistingFormData.seedExamples = seedExamples; - - //Extract filePath from the existing qna.yaml file path - const currentFilePath = change.file.split('/').slice(1, -1).join('/'); - skillEditFormData.formData.filePath = currentFilePath + '/'; - - // Set the oldFilesPath to the existing qna.yaml file path. - skillEditFormData.oldFilesPath = skillEditFormData.formData.filePath; - } - if (change.file.includes('attribution.txt')) { - const attributionData = parseAttributionContent(change.content); - console.log('Parsed skill attribution data:', attributionData); - // Populate the form fields with attribution data - skillExistingFormData.titleWork = attributionData.title_of_work; - skillExistingFormData.licenseWork = attributionData.license_of_the_work; - skillExistingFormData.creators = attributionData.creator_names; - } - } - }); - } - setSkillEditFormData(skillEditFormData); - } else { - console.error('Failed to get branch changes:', result.error); - } - setIsLoading(false); - } catch (error) { - console.error('Error fetching branch changes:', error); - } - }; - fetchBranchChanges(); - }, [branchName, isDraft, session?.user?.email, session?.user?.name]); - - const parseAttributionContent = (content: string): AttributionData => { - const lines = content.split('\n'); - const attributionData: { [key: string]: string } = {}; - lines.forEach((line) => { - const [key, ...value] = line.split(':'); - if (key && value) { - const normalizedKey = key.trim().toLowerCase().replace(/\s+/g, '_'); - attributionData[normalizedKey] = value.join(':').trim(); - } - }); - return attributionData as unknown as AttributionData; - }; - - const handleOnClose = () => { - router.push('/dashboard'); - setIsLoading(false); - }; - - if (isLoading) { - return ( - - -
{loadingMsg}
-
-
- ); - } - - return ; -}; - -export default EditSkillNative; diff --git a/src/components/Contribute/Knowledge/Edit/github/EditKnowledge.tsx b/src/components/Contribute/Knowledge/Edit/github/EditKnowledge.tsx new file mode 100644 index 00000000..81d596ce --- /dev/null +++ b/src/components/Contribute/Knowledge/Edit/github/EditKnowledge.tsx @@ -0,0 +1,77 @@ +// src/app/components/contribute/EditKnowledge/github/EditKnowledge.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { KnowledgeEditFormData, PullRequest } from '@/types'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import { fetchDraftKnowledgeChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchKnowledgePRData } from '@/components/Contribute/fetchUtils'; +import KnowledgeWizard from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard'; +import { fetchPullRequests } from '@/utils/github'; + +interface EditKnowledgeClientComponentProps { + branchName: string; + isDraft: boolean; +} + +const EditKnowledge: React.FC = ({ branchName, isDraft }) => { + const { data: session } = useSession(); + const { envConfig, loaded } = useEnvConfig(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [knowledgeEditFormData, setKnowledgeEditFormData] = useState(); + const router = useRouter(); + + useEffect(() => { + if (isDraft) { + fetchDraftKnowledgeChanges({ branchName, setIsLoading, setLoadingMsg, setKnowledgeEditFormData }); + return; + } + + setLoadingMsg('Fetching knowledge data from PR : ' + branchName); + + const fetchPRData = async () => { + if (!loaded || !session?.accessToken) { + return; + } + + const pullRequests: PullRequest[] = await fetchPullRequests(session.accessToken, envConfig); + const pr = pullRequests.find((pullRequest) => pullRequest.head.ref === branchName); + if (!pr) { + return; + } + + const { editFormData, error } = await fetchKnowledgePRData(session, envConfig, String(pr.number)); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setKnowledgeEditFormData(editFormData); + }; + fetchPRData(); + }, [session, loaded, envConfig, branchName, isDraft]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading) { + return ( + handleOnClose()}> + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default EditKnowledge; diff --git a/src/components/Contribute/Knowledge/Edit/native/EditKnowledge.tsx b/src/components/Contribute/Knowledge/Edit/native/EditKnowledge.tsx new file mode 100644 index 00000000..88d48c1e --- /dev/null +++ b/src/components/Contribute/Knowledge/Edit/native/EditKnowledge.tsx @@ -0,0 +1,63 @@ +// src/app/components/contribute/EditKnowledge/native/EditKnowledge.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { KnowledgeEditFormData } from '@/types'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { fetchDraftKnowledgeChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchKnowledgeBranchChanges } from '@/components/Contribute/fetchUtils'; +import KnowledgeWizard from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard'; + +interface EditKnowledgeClientComponentProps { + branchName: string; + isDraft: boolean; +} + +const EditKnowledgeNative: React.FC = ({ branchName, isDraft }) => { + const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [knowledgeEditFormData, setKnowledgeEditFormData] = useState(); + const router = useRouter(); + + useEffect(() => { + if (isDraft) { + fetchDraftKnowledgeChanges({ branchName, setIsLoading, setLoadingMsg, setKnowledgeEditFormData }); + return; + } + + setLoadingMsg('Fetching knowledge data from branch : ' + branchName); + const fetchFormData = async () => { + const { editFormData, error } = await fetchKnowledgeBranchChanges(session, branchName); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setKnowledgeEditFormData(editFormData); + }; + fetchFormData(); + }, [branchName, isDraft, session]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading) { + return ( + handleOnClose()}> + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default EditKnowledgeNative; diff --git a/src/components/Contribute/Knowledge/Github/index.tsx b/src/components/Contribute/Knowledge/Github/index.tsx deleted file mode 100644 index a5477ba3..00000000 --- a/src/components/Contribute/Knowledge/Github/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -// src/components/Contribute/Knowledge/Github/index.tsx -'use client'; -import React from 'react'; -import { KnowledgeEditFormData } from '@/types'; -import KnowledgeWizard from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard'; - -export interface KnowledgeFormProps { - knowledgeEditFormData?: KnowledgeEditFormData; -} -export const KnowledgeFormNative: React.FunctionComponent = ({ knowledgeEditFormData }) => ( - -); - -export default KnowledgeFormNative; diff --git a/src/components/Contribute/Knowledge/DocumentInformation/DocumentInformation.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/DocumentInformation/DocumentInformation.tsx similarity index 88% rename from src/components/Contribute/Knowledge/DocumentInformation/DocumentInformation.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/DocumentInformation/DocumentInformation.tsx index 386be714..7738a73e 100644 --- a/src/components/Contribute/Knowledge/DocumentInformation/DocumentInformation.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/DocumentInformation/DocumentInformation.tsx @@ -1,10 +1,10 @@ // src/components/Contribute/Knowledge/Native/DocumentInformation/DocumentInformation.tsx import React from 'react'; import { Button, Flex, FlexItem } from '@patternfly/react-core'; -import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import { KnowledgeFile } from '@/types'; -import { UploadFile } from '@/components/Contribute/Knowledge/UploadFile'; +import { UploadFile } from '@/components/Contribute/Knowledge/KnowledgeWizard/UploadFile'; import WizardPageHeader from '@/components/Common/WizardPageHeader'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; interface Props { existingFiles: KnowledgeFile[]; @@ -30,7 +30,7 @@ const DocumentInformation: React.FC = ({ existingFiles, setExistingFiles, href="https://docs.instructlab.ai/taxonomy/knowledge/#knowledge-yaml-examples" target="_blank" rel="noopener noreferrer" - icon={} + icon={} iconPosition="end" > Learn about accepted sources diff --git a/src/components/Contribute/Knowledge/FileConversionModal.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/FileConversionModal.tsx similarity index 100% rename from src/components/Contribute/Knowledge/FileConversionModal.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/FileConversionModal.tsx diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeFileSelectModal.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeFileSelectModal.tsx similarity index 100% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeFileSelectModal.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeFileSelectModal.tsx diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair.tsx similarity index 100% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair.tsx diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPairs.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPairs.tsx similarity index 95% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPairs.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPairs.tsx index 5fa4ad84..4d6c3ef4 100644 --- a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPairs.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPairs.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { KnowledgeSeedExample, QuestionAndAnswerPair } from '@/types'; import { Form } from '@patternfly/react-core'; import { t_global_spacer_md as MdSpacerSize } from '@patternfly/react-tokens'; -import KnowledgeQuestionAnswerPair from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair'; +import KnowledgeQuestionAnswerPair from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair'; interface Props { seedExample: KnowledgeSeedExample; diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExampleCard.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExampleCard.tsx similarity index 98% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExampleCard.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExampleCard.tsx index 3743a122..576b303b 100644 --- a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExampleCard.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExampleCard.tsx @@ -37,11 +37,11 @@ import { import type { KnowledgeFile, KnowledgeSeedExample } from '@/types'; import { QuestionAndAnswerPair } from '@/types'; import TruncatedText from '@/components/Common/TruncatedText'; -import KnowledgeFileSelectModal from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeFileSelectModal'; +import KnowledgeFileSelectModal from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeFileSelectModal'; import { createEmptyKnowledgeSeedExample, MAX_CONTRIBUTION_Q_AND_A_WORDS, validateContext } from '@/components/Contribute/Utils/seedExampleUtils'; -import { getSeedExampleTitle } from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/utils'; +import { getSeedExampleTitle } from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/utils'; import './KnowledgeSeedExamples.scss'; -import KnowledgeQuestionAnswerPair from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair'; +import KnowledgeQuestionAnswerPair from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeQuestionAnswerPair'; import { getWordCount } from '@/components/Contribute/Utils/contributionUtils'; const useForceUpdate = () => { diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamples.scss b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamples.scss similarity index 100% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamples.scss rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamples.scss diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamples.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamples.tsx similarity index 95% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamples.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamples.tsx index fb72010c..fde87b48 100644 --- a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamples.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamples.tsx @@ -1,15 +1,16 @@ // src/components/Contribute/Knowledge/KnowledgeSeedExampleNative/KnowledgeQuestionAnswerPairsNative.tsx import React, { useState } from 'react'; import { Alert, Bullseye, Button, Flex, FlexItem, Spinner } from '@patternfly/react-core'; -import { ExternalLinkAltIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { PlusCircleIcon } from '@patternfly/react-icons'; import type { KnowledgeFile, KnowledgeSeedExample } from '@/types'; import WizardPageHeader from '@/components/Common/WizardPageHeader'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; import { createEmptyKnowledgeSeedExample, handleKnowledgeSeedExamplesAnswerBlur, handleKnowledgeSeedExamplesQuestionBlur } from '@/components/Contribute/Utils/seedExampleUtils'; -import KnowledgeSeedExampleCard from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExampleCard'; +import KnowledgeSeedExampleCard from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExampleCard'; interface Props { isGithubMode: boolean; @@ -93,7 +94,7 @@ const KnowledgeSeedExamples: React.FC = ({ isGithubMode, filesToUpload, u href="https://docs.instructlab.ai/taxonomy/knowledge/#knowledge-yaml-examples" target="_blank" rel="noopener noreferrer" - icon={} + icon={} iconPosition="end" > Learn more about seed data diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection.tsx similarity index 98% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection.tsx index e42ce18e..a42d01cc 100644 --- a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection.tsx @@ -14,7 +14,7 @@ import { } from '@patternfly/react-core'; import type { KnowledgeSeedExample } from '@/types'; import { t_global_spacer_sm as SmSpacerSize } from '@patternfly/react-tokens/dist/esm/t_global_spacer_sm'; -import { getSeedExampleTitle } from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/utils'; +import { getSeedExampleTitle } from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/utils'; interface Props { seedExamples: KnowledgeSeedExample[]; diff --git a/src/components/Contribute/Knowledge/KnowledgeSeedExamples/utils.ts b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/utils.ts similarity index 100% rename from src/components/Contribute/Knowledge/KnowledgeSeedExamples/utils.ts rename to src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/utils.ts diff --git a/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard.tsx index 1955f8e1..dabd6f07 100644 --- a/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard.tsx @@ -3,8 +3,8 @@ import React, { useEffect, useState } from 'react'; import './knowledge.css'; import { useSession } from 'next-auth/react'; -import DocumentInformation from '@/components/Contribute/Knowledge/DocumentInformation/DocumentInformation'; -import KnowledgeSeedExamples from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamples'; +import DocumentInformation from '@/components/Contribute/Knowledge/KnowledgeWizard/DocumentInformation/DocumentInformation'; +import KnowledgeSeedExamples from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamples'; import { ContributionFormData, KnowledgeEditFormData, KnowledgeFormData, KnowledgeSeedExample, KnowledgeYamlData } from '@/types'; import { useRouter } from 'next/navigation'; import { Breadcrumb, BreadcrumbItem, Button, PageBreadcrumb, ValidatedOptions } from '@patternfly/react-core'; @@ -22,15 +22,16 @@ import { updateGithubKnowledgeData, updateNativeKnowledgeData } from '@/components/Contribute/Utils/submitUtils'; -import AttributionInformation from '@/components/Contribute/AttributionInformation/AttributionInformation'; +import AttributionInformation from '@/components/Contribute/ContributionWizard/AttributionInformation/AttributionInformation'; import { ContributionWizard, StepStatus, StepType } from '@/components/Contribute/ContributionWizard/ContributionWizard'; import { KnowledgeSchemaVersion } from '@/types/const'; +import { useEnvConfig } from '@/context/EnvConfigContext'; import { YamlFileUploadModal } from '@/components/Contribute/YamlFileUploadModal'; import ContributeAlertGroup from '@/components/Contribute/ContributeAlertGroup'; import { addYamlUploadKnowledge } from '@/components/Contribute/Utils/uploadUtils'; -import ReviewSubmission from '@/components/Contribute/ReviewSubmission/ReviewSubmission'; -import KnowledgeSeedExamplesReviewSection from '@/components/Contribute/Knowledge/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection'; -import DetailsPage from '@/components/Contribute/DetailsPage/DetailsPage'; +import ReviewSubmission from '@/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSubmission'; +import KnowledgeSeedExamplesReviewSection from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/KnowledgeSeedExamplesReviewSection'; +import DetailsPage from '@/components/Contribute/ContributionWizard/DetailsPage/DetailsPage'; import { getDefaultKnowledgeFormData } from '@/components/Contribute/Utils/contributionUtils'; import { storeDraftData, deleteDraftData, doSaveDraft, isDraftDataExist, storeDraftKnowledgeFile } from '@/components/Contribute/Utils/autoSaveUtils'; @@ -43,6 +44,8 @@ const STEP_IDS = ['details', 'resource-documentation', 'uploaded-documents', 'at export const KnowledgeWizard: React.FunctionComponent = ({ knowledgeEditFormData, isGithubMode }) => { const { data: session } = useSession(); + const { envConfig } = useEnvConfig(); + const [knowledgeFormData, setKnowledgeFormData] = React.useState( knowledgeEditFormData?.formData ? { @@ -108,6 +111,7 @@ export const KnowledgeWizard: React.FunctionComponent = ({ k }); storeDraftData( knowledgeFormData.branchName, + knowledgeFormData.filePath, draftContributionStr, !!knowledgeEditFormData?.isSubmitted, knowledgeEditFormData?.oldFilesPath || '' @@ -302,7 +306,7 @@ export const KnowledgeWizard: React.FunctionComponent = ({ k if (knowledgeEditFormData && knowledgeEditFormData.isSubmitted) { const result = isGithubMode - ? await updateGithubKnowledgeData(session, knowledgeFormData, knowledgeEditFormData, updateActionGroupAlertContent) + ? await updateGithubKnowledgeData(session, envConfig, knowledgeFormData, knowledgeEditFormData, updateActionGroupAlertContent) : await updateNativeKnowledgeData(knowledgeFormData, knowledgeEditFormData, updateActionGroupAlertContent); if (result) { //Remove draft if present in the local storage @@ -344,10 +348,10 @@ export const KnowledgeWizard: React.FunctionComponent = ({ k to="/" onClick={(e) => { e.preventDefault(); - router.push('/contribute/knowledge'); + router.push('/dashboard'); }} > - Contribute knowledge + My contributions {`Edit${knowledgeEditFormData?.isDraft ? ' draft' : ''} knowledge contribution`} diff --git a/src/components/Contribute/Knowledge/MultFileUploadArea.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/MultFileUploadArea.tsx similarity index 100% rename from src/components/Contribute/Knowledge/MultFileUploadArea.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/MultFileUploadArea.tsx diff --git a/src/components/Contribute/Knowledge/UploadFile.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/UploadFile.tsx similarity index 99% rename from src/components/Contribute/Knowledge/UploadFile.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/UploadFile.tsx index f2ec678b..c9248bb8 100644 --- a/src/components/Contribute/Knowledge/UploadFile.tsx +++ b/src/components/Contribute/Knowledge/KnowledgeWizard/UploadFile.tsx @@ -20,9 +20,9 @@ import { UploadIcon } from '@patternfly/react-icons'; import React, { useState, useEffect } from 'react'; import { FileRejection, DropEvent } from 'react-dropzone'; import { KnowledgeFile } from '@/types'; -import UploadFromGitModal from '@/components/Contribute/Knowledge/UploadFromGitModal'; -import MultiFileUploadArea from '@/components/Contribute/Knowledge/MultFileUploadArea'; -import FileConversionModal from '@/components/Contribute/Knowledge/FileConversionModal'; +import UploadFromGitModal from '@/components/Contribute/Knowledge/KnowledgeWizard/UploadFromGitModal'; +import MultiFileUploadArea from '@/components/Contribute/Knowledge/KnowledgeWizard/MultFileUploadArea'; +import FileConversionModal from '@/components/Contribute/Knowledge/KnowledgeWizard/FileConversionModal'; import { useFeatureFlags } from '@/context/FeatureFlagsContext'; interface ReadFile { diff --git a/src/components/Contribute/Knowledge/UploadFromGitModal.tsx b/src/components/Contribute/Knowledge/KnowledgeWizard/UploadFromGitModal.tsx similarity index 100% rename from src/components/Contribute/Knowledge/UploadFromGitModal.tsx rename to src/components/Contribute/Knowledge/KnowledgeWizard/UploadFromGitModal.tsx diff --git a/src/components/Contribute/Knowledge/Native/index.tsx b/src/components/Contribute/Knowledge/Native/index.tsx deleted file mode 100644 index c61cc7cf..00000000 --- a/src/components/Contribute/Knowledge/Native/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -// src/components/Contribute/Native/Knowledge/index.tsx -'use client'; -import React from 'react'; -import { KnowledgeEditFormData } from '@/types'; -import KnowledgeWizard from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeWizard'; - -export interface KnowledgeFormProps { - knowledgeEditFormData?: KnowledgeEditFormData; -} -export const KnowledgeFormNative: React.FunctionComponent = ({ knowledgeEditFormData }) => ( - -); - -export default KnowledgeFormNative; diff --git a/src/components/Contribute/Knowledge/View/ViewKnowledge.tsx b/src/components/Contribute/Knowledge/View/ViewKnowledge.tsx new file mode 100644 index 00000000..fe0a301d --- /dev/null +++ b/src/components/Contribute/Knowledge/View/ViewKnowledge.tsx @@ -0,0 +1,202 @@ +// src/app/components/contribute/EditKnowledge/native/EditKnowledge.tsx +'use client'; + +import * as React from 'react'; +import { KnowledgeEditFormData } from '@/types'; +import { useRouter } from 'next/navigation'; +import { + PageSection, + Flex, + FlexItem, + Title, + Content, + PageBreadcrumb, + Breadcrumb, + BreadcrumbItem, + PageGroup, + Label, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription +} from '@patternfly/react-core'; +import { CatalogIcon, PficonTemplateIcon } from '@patternfly/react-icons'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import ViewContributionSection from '@/components/Common/ViewContributionSection'; +import ViewKnowledgeSeedExample from '@/components/Contribute/Knowledge/View/ViewKnowledgeSeedExample'; + +interface ViewKnowledgeProps { + knowledgeEditFormData: KnowledgeEditFormData; +} + +const ViewKnowledgeNative: React.FC = ({ knowledgeEditFormData }) => { + const router = useRouter(); + const { + envConfig: { isGithubMode } + } = useEnvConfig(); + + return ( + + + + { + e.preventDefault(); + router.push('/dashboard'); + }} + > + My contributions + + {knowledgeEditFormData?.formData?.submissionSummary || `Draft knowledge contribution`} + + + + + + + + + + + {knowledgeEditFormData?.formData?.submissionSummary || `Draft knowledge contribution`} + + + {/** TODO: Add actions here */} + + + + + + {knowledgeEditFormData.isDraft ? ( + + + + ) : null} + + + + + Knowledge contributions improve a model’s ability to answer questions accurately. They consist of questions and answers, and documents + which back up that data. + + + + + + + {/* Author Information */} + + + Contributors + +
{knowledgeEditFormData.formData.name}
+
{knowledgeEditFormData.formData.email}
+
+ + ]} + /> +
+ + + + Contribution summary + +
{knowledgeEditFormData.formData.submissionSummary}
+
+ , + + Directory path + +
{knowledgeEditFormData.formData.filePath}
+
+
+ ]} + /> +
+ + {/* Attribution Information */} + {isGithubMode ? ( + + + Title + +
{knowledgeEditFormData.formData.titleWork}
+
+ , + + Link to work + +
{knowledgeEditFormData.formData.linkWork}
+
+
, + + Revision + +
{knowledgeEditFormData.formData.revision}
+
+
, + + License of the work + +
{knowledgeEditFormData.formData.licenseWork}
+
+
, + + Authors + +
{knowledgeEditFormData.formData.creators}
+
+
+ ]} + /> +
+ ) : null} + + {/* Seed Examples */} + + + Examples + + + {knowledgeEditFormData.formData.seedExamples?.map((seedExample, index) => ( + + + + ))} + + + + ]} + /> + +
+
+
+ ); +}; + +export default ViewKnowledgeNative; diff --git a/src/components/Contribute/Knowledge/View/ViewKnowledgeSeedExample.tsx b/src/components/Contribute/Knowledge/View/ViewKnowledgeSeedExample.tsx new file mode 100644 index 00000000..0922d7a0 --- /dev/null +++ b/src/components/Contribute/Knowledge/View/ViewKnowledgeSeedExample.tsx @@ -0,0 +1,56 @@ +// src/components/Contribute/Knowledge/Native/KnowledgeSeedExampleNative/KnowledgeQuestionAnswerPairsNative.tsx +import React from 'react'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionToggle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm +} from '@patternfly/react-core'; +import type { KnowledgeSeedExample } from '@/types'; +import { t_global_spacer_sm as SmSpacerSize } from '@patternfly/react-tokens/dist/esm/t_global_spacer_sm'; +import { getSeedExampleTitle } from '@/components/Contribute/Knowledge/KnowledgeWizard/KnowledgeSeedExamples/utils'; + +interface Props { + seedExample: KnowledgeSeedExample; + index: number; +} + +const ViewKnowledgeSeedExample: React.FC = ({ seedExample, index }) => { + const [expanded, setExpanded] = React.useState(false); + + return ( + + + setExpanded((prev) => !prev)} id={`seed-example-toggle-${index}`}> + {getSeedExampleTitle(seedExample, index)} + + + + + Context + {seedExample.context} + + {seedExample.questionAndAnswers.map((qa, qaIndex) => ( + + + Question + {qa.question} + + + Answer + {qa.answer} + + + ))} + + + + + ); +}; + +export default ViewKnowledgeSeedExample; diff --git a/src/components/Contribute/Knowledge/View/github/ViewKnowledgePage.tsx b/src/components/Contribute/Knowledge/View/github/ViewKnowledgePage.tsx new file mode 100644 index 00000000..bef5a8e0 --- /dev/null +++ b/src/components/Contribute/Knowledge/View/github/ViewKnowledgePage.tsx @@ -0,0 +1,77 @@ +// src/app/components/contribute/EditKnowledge/github/EditKnowledge.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { KnowledgeEditFormData, PullRequest } from '@/types'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import { fetchDraftKnowledgeChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchKnowledgePRData } from '@/components/Contribute/fetchUtils'; +import ViewKnowledge from '@/components/Contribute/Knowledge/View/ViewKnowledge'; +import { fetchPullRequests } from '@/utils/github'; + +interface Props { + branchName: string; + isDraft: boolean; +} + +const ViewKnowledgePage: React.FC = ({ branchName, isDraft }) => { + const router = useRouter(); + const { data: session } = useSession(); + const { envConfig, loaded } = useEnvConfig(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [knowledgeEditFormData, setKnowledgeEditFormData] = useState(); + + useEffect(() => { + if (isDraft) { + fetchDraftKnowledgeChanges({ branchName, setIsLoading, setLoadingMsg, setKnowledgeEditFormData }); + return; + } + + setLoadingMsg('Fetching knowledge data from PR : ' + branchName); + + const fetchPRData = async () => { + if (!loaded || !session?.accessToken) { + return; + } + + const pullRequests: PullRequest[] = await fetchPullRequests(session.accessToken, envConfig); + const pr = pullRequests.find((pullRequest) => pullRequest.head.ref === branchName); + if (!pr) { + return; + } + + const { editFormData, error } = await fetchKnowledgePRData(session, envConfig, String(pr.number)); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setKnowledgeEditFormData(editFormData); + }; + fetchPRData(); + }, [session, loaded, envConfig, branchName, isDraft]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading || !knowledgeEditFormData?.formData) { + return ( + handleOnClose()}> + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default ViewKnowledgePage; diff --git a/src/components/Contribute/Knowledge/View/native/ViewKnowledgePage.tsx b/src/components/Contribute/Knowledge/View/native/ViewKnowledgePage.tsx new file mode 100644 index 00000000..542ae996 --- /dev/null +++ b/src/components/Contribute/Knowledge/View/native/ViewKnowledgePage.tsx @@ -0,0 +1,63 @@ +// src/app/components/contribute/EditKnowledge/native/EditKnowledge.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { KnowledgeEditFormData } from '@/types'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { fetchDraftKnowledgeChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchKnowledgeBranchChanges } from '@/components/Contribute/fetchUtils'; +import ViewKnowledge from '@/components/Contribute/Knowledge/View/ViewKnowledge'; + +interface Props { + branchName: string; + isDraft: boolean; +} + +const ViewKnowledgePage: React.FC = ({ branchName, isDraft }) => { + const router = useRouter(); + const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [knowledgeEditFormData, setKnowledgeEditFormData] = useState(); + + useEffect(() => { + if (isDraft) { + fetchDraftKnowledgeChanges({ branchName, setIsLoading, setLoadingMsg, setKnowledgeEditFormData }); + return; + } + + setLoadingMsg('Fetching knowledge data from branch : ' + branchName); + const fetchFormData = async () => { + const { editFormData, error } = await fetchKnowledgeBranchChanges(session, branchName); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setKnowledgeEditFormData(editFormData); + }; + fetchFormData(); + }, [branchName, isDraft, session]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading || !knowledgeEditFormData?.formData) { + return ( + handleOnClose()}> + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default ViewKnowledgePage; diff --git a/src/components/Contribute/Skill/Edit/github/EditSkill.tsx b/src/components/Contribute/Skill/Edit/github/EditSkill.tsx new file mode 100644 index 00000000..8e3491d8 --- /dev/null +++ b/src/components/Contribute/Skill/Edit/github/EditSkill.tsx @@ -0,0 +1,76 @@ +// src/app/components/contribute/EditSkill/github/EditSkill.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { PullRequest, SkillEditFormData } from '@/types'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import { fetchDraftSkillChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchSkillPRData } from '@/components/Contribute/fetchUtils'; +import SkillWizard from '@/components/Contribute/Skill/SkillWizard/SkillWizard'; +import { fetchPullRequests } from '@/utils/github'; + +interface EditSkillClientComponentProps { + branchName: string; + isDraft: boolean; +} + +const EditSkill: React.FC = ({ branchName, isDraft }) => { + const router = useRouter(); + const { data: session } = useSession(); + const { envConfig, loaded } = useEnvConfig(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [skillEditFormData, setSkillEditFormData] = useState(); + + useEffect(() => { + if (isDraft) { + fetchDraftSkillChanges({ branchName, setIsLoading, setLoadingMsg, setSkillEditFormData }); + return; + } + + const fetchPRData = async () => { + setLoadingMsg('Fetching skills data from PR : ' + branchName); + if (!loaded || !session?.accessToken) { + return; + } + + const pullRequests: PullRequest[] = await fetchPullRequests(session.accessToken, envConfig); + const pr = pullRequests.find((pullRequest) => pullRequest.head.ref === branchName); + if (!pr) { + return; + } + + const { editFormData, error } = await fetchSkillPRData(session, envConfig, String(pr.number)); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setSkillEditFormData(editFormData); + }; + fetchPRData(); + }, [session, loaded, envConfig, branchName, isDraft]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading) { + return ( + + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default EditSkill; diff --git a/src/components/Contribute/Skill/Edit/native/EditSkill.tsx b/src/components/Contribute/Skill/Edit/native/EditSkill.tsx new file mode 100644 index 00000000..3dff1b96 --- /dev/null +++ b/src/components/Contribute/Skill/Edit/native/EditSkill.tsx @@ -0,0 +1,63 @@ +// src/app/components/contribute/EditSkill/native/EditSkill.tsx +'use client'; + +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { SkillEditFormData } from '@/types'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { useSession } from 'next-auth/react'; +import { fetchDraftSkillChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchSkillBranchChanges } from '@/components/Contribute/fetchUtils'; +import SkillWizard from '@/components/Contribute/Skill/SkillWizard/SkillWizard'; + +interface EditSkillClientComponentProps { + branchName: string; + isDraft: boolean; +} + +const EditSkillNative: React.FC = ({ branchName, isDraft }) => { + const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [skillEditFormData, setSkillEditFormData] = useState(); + const router = useRouter(); + + useEffect(() => { + if (isDraft) { + fetchDraftSkillChanges({ branchName, setIsLoading, setLoadingMsg, setSkillEditFormData }); + return; + } + + const fetchBranchChanges = async () => { + setLoadingMsg('Fetching skills data from branch: ' + branchName); + const { editFormData, error } = await fetchSkillBranchChanges(session, branchName); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setSkillEditFormData(editFormData); + }; + fetchBranchChanges(); + }, [branchName, isDraft, session]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading) { + return ( + + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default EditSkillNative; diff --git a/src/components/Contribute/Skill/Github/index.tsx b/src/components/Contribute/Skill/Github/index.tsx deleted file mode 100644 index c20621e0..00000000 --- a/src/components/Contribute/Skill/Github/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// src/components/Contribute/Skill/Github/index.tsx -'use client'; -import React from 'react'; -import { SkillEditFormData } from '@/types'; -import SkillWizard from '@/components/Contribute/Skill/SkillWizard/SkillWizard'; - -export interface SkillFormProps { - skillEditFormData?: SkillEditFormData; -} - -export const SkillFormGithub: React.FunctionComponent = ({ skillEditFormData }) => ( - -); - -export default SkillFormGithub; diff --git a/src/components/Contribute/Skill/Native/index.tsx b/src/components/Contribute/Skill/Native/index.tsx deleted file mode 100644 index 5c4b3cb3..00000000 --- a/src/components/Contribute/Skill/Native/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// src/components/Contribute/Skill/Native/index.tsx -'use client'; -import React from 'react'; -import { SkillEditFormData } from '@/types'; -import SkillWizard from '@/components/Contribute/Skill/SkillWizard/SkillWizard'; - -export interface SkillFormProps { - skillEditFormData?: SkillEditFormData; -} - -export const SkillFormNative: React.FunctionComponent = ({ skillEditFormData }) => ( - -); - -export default SkillFormNative; diff --git a/src/components/Contribute/Skill/SkillSeedExamples/SkillQuestionAnswerPairs.tsx b/src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillQuestionAnswerPairs.tsx similarity index 100% rename from src/components/Contribute/Skill/SkillSeedExamples/SkillQuestionAnswerPairs.tsx rename to src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillQuestionAnswerPairs.tsx diff --git a/src/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamples.tsx b/src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamples.tsx similarity index 95% rename from src/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamples.tsx rename to src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamples.tsx index 2e6de9c1..c917219f 100644 --- a/src/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamples.tsx +++ b/src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamples.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { SkillSeedExample } from '@/types'; import { Accordion, AccordionContent, AccordionItem, AccordionToggle, Button, Flex, FlexItem } from '@patternfly/react-core'; -import { ExternalLinkAltIcon, PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; +import { PlusCircleIcon, TrashIcon } from '@patternfly/react-icons'; import { createEmptySkillSeedExample, handleSkillSeedExamplesAnswerBlur, @@ -12,8 +12,9 @@ import { handleSkillSeedExamplesQuestionInputChange, toggleSkillSeedExamplesExpansion } from '@/components/Contribute/Utils/seedExampleUtils'; -import SkillQuestionAnswerPairs from '@/components/Contribute/Skill/SkillSeedExamples/SkillQuestionAnswerPairs'; +import SkillQuestionAnswerPairs from '@/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillQuestionAnswerPairs'; import WizardPageHeader from '@/components/Common/WizardPageHeader'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; interface Props { seedExamples: SkillSeedExample[]; @@ -70,7 +71,7 @@ const SkillSeedExamples: React.FC = ({ seedExamples, onUpdateSeedExamples href="https://docs.instructlab.ai/taxonomy/knowledge/#knowledge-yaml-examples" target="_blank" rel="noopener noreferrer" - icon={} + icon={} iconPosition="end" > Learn more about seed examples diff --git a/src/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamplesReviewSection.tsx b/src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamplesReviewSection.tsx similarity index 100% rename from src/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamplesReviewSection.tsx rename to src/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamplesReviewSection.tsx diff --git a/src/components/Contribute/Skill/SkillWizard/SkillWizard.tsx b/src/components/Contribute/Skill/SkillWizard/SkillWizard.tsx index e050f45c..b9322417 100644 --- a/src/components/Contribute/Skill/SkillWizard/SkillWizard.tsx +++ b/src/components/Contribute/Skill/SkillWizard/SkillWizard.tsx @@ -1,4 +1,4 @@ -// src/components/Contribute/Sill/SkillWizard.tsx +// src/components/Contribute/Skill/SkillWizard/SkillWizard.tsx 'use client'; import React, { useEffect, useState } from 'react'; import { useSession } from 'next-auth/react'; @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { ValidatedOptions, Button, PageBreadcrumb, Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; import { SkillSchemaVersion } from '@/types/const'; import { ContributionFormData, SkillEditFormData, SkillFormData, SkillSeedExample, SkillYamlData } from '@/types'; +import { useEnvConfig } from '@/context/EnvConfigContext'; import { ActionGroupAlertContent } from '@/components/Contribute/types'; import { isAttributionInformationValid, isSkillSeedExamplesValid, isDetailsValid } from '@/components/Contribute/Utils/validationUtils'; import { @@ -16,14 +17,14 @@ import { } from '@/components/Contribute/Utils/submitUtils'; import { addYamlUploadSkill } from '@/components/Contribute/Utils/uploadUtils'; import { getDefaultSkillFormData } from '@/components/Contribute/Utils/contributionUtils'; -import AttributionInformation from '@/components/Contribute/AttributionInformation/AttributionInformation'; +import AttributionInformation from '@/components/Contribute/ContributionWizard/AttributionInformation/AttributionInformation'; import { ContributionWizard, StepStatus, StepType } from '@/components/Contribute/ContributionWizard/ContributionWizard'; import { YamlFileUploadModal } from '@/components/Contribute/YamlFileUploadModal'; import ContributeAlertGroup from '@/components/Contribute/ContributeAlertGroup'; -import ReviewSubmission from '@/components/Contribute/ReviewSubmission/ReviewSubmission'; -import SkillSeedExamples from '@/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamples'; -import SkillSeedExamplesReviewSection from '@/components/Contribute/Skill/SkillSeedExamples/SkillSeedExamplesReviewSection'; -import DetailsPage from '@/components/Contribute/DetailsPage/DetailsPage'; +import ReviewSubmission from '@/components/Contribute/ContributionWizard/ReviewSubmission/ReviewSubmission'; +import SkillSeedExamples from '@/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamples'; +import SkillSeedExamplesReviewSection from '@/components/Contribute/Skill/SkillWizard/SkillSeedExamples/SkillSeedExamplesReviewSection'; +import DetailsPage from '@/components/Contribute/ContributionWizard/DetailsPage/DetailsPage'; import { storeDraftData, deleteDraftData, doSaveDraft, isDraftDataExist } from '@/components/Contribute/Utils/autoSaveUtils'; import './skills.css'; @@ -37,6 +38,7 @@ const STEP_IDS = ['details', 'seed-examples', 'attribution-info', 'review-submis export const SkillWizard: React.FunctionComponent = ({ skillEditFormData, isGithubMode }) => { const { data: session } = useSession(); + const { envConfig } = useEnvConfig(); const [skillFormData, setSkillFormData] = React.useState( skillEditFormData?.formData ? { @@ -82,7 +84,13 @@ export const SkillWizard: React.FunctionComponent = ({ skillEditFormData, return; } - storeDraftData(skillFormData.branchName, JSON.stringify(skillFormData), !!skillEditFormData?.isSubmitted, skillEditFormData?.oldFilesPath || ''); + storeDraftData( + skillFormData.branchName, + skillFormData.filePath, + JSON.stringify(skillFormData), + !!skillEditFormData?.isSubmitted, + skillEditFormData?.oldFilesPath || '' + ); }, [skillEditFormData?.isSubmitted, skillEditFormData?.oldFilesPath, skillFormData]); const steps: StepType[] = React.useMemo( @@ -184,7 +192,7 @@ export const SkillWizard: React.FunctionComponent = ({ skillEditFormData, // If the PR number is present it means the draft is for the submitted PR. if (skillEditFormData && skillEditFormData.isSubmitted) { const result = isGithubMode - ? await updateGithubSkillData(session, skillFormData, skillEditFormData, updateActionGroupAlertContent) + ? await updateGithubSkillData(session, envConfig, skillFormData, skillEditFormData, updateActionGroupAlertContent) : await updateNativeSkillData(skillFormData, skillEditFormData, updateActionGroupAlertContent); if (result) { //Remove draft if present in the local storage @@ -231,10 +239,10 @@ export const SkillWizard: React.FunctionComponent = ({ skillEditFormData, to="/" onClick={(e) => { e.preventDefault(); - router.push('/contribute/skill'); + router.push('/dashboard'); }} > - Contribute skills + My contributions {`Edit${skillEditFormData?.isDraft ? ' draft' : ''} skills contribution`} diff --git a/src/components/Contribute/Skill/View/ViewSkill.tsx b/src/components/Contribute/Skill/View/ViewSkill.tsx new file mode 100644 index 00000000..ee788a4d --- /dev/null +++ b/src/components/Contribute/Skill/View/ViewSkill.tsx @@ -0,0 +1,180 @@ +// src/app/components/contribute/Skill/view/ViewSkill.tsx +'use client'; + +import * as React from 'react'; +import { SkillEditFormData } from '@/types'; +import { useRouter } from 'next/navigation'; +import { + PageSection, + Flex, + FlexItem, + Title, + Content, + PageBreadcrumb, + Breadcrumb, + BreadcrumbItem, + PageGroup, + Label, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription +} from '@patternfly/react-core'; +import { CatalogIcon, PficonTemplateIcon } from '@patternfly/react-icons'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import ViewContributionSection from '@/components/Common/ViewContributionSection'; +import ViewSkillSeedExample from '@/components/Contribute/Skill/View/ViewSkillSeedExample'; + +interface ViewKnowledgeProps { + skillEditFormData: SkillEditFormData; +} + +const ViewKnowledgeNative: React.FC = ({ skillEditFormData }) => { + const router = useRouter(); + const { + envConfig: { isGithubMode } + } = useEnvConfig(); + + return ( + + + + { + e.preventDefault(); + router.push('/dashboard'); + }} + > + My contributions + + {skillEditFormData?.formData?.submissionSummary || `Draft skill contribution`} + + + + + + + + + {skillEditFormData?.formData?.submissionSummary || `Draft knowledge contribution`} + + + + + + {skillEditFormData.isDraft ? ( + + + + ) : null} + + + + + Knowledge contributions improve a model’s ability to answer questions accurately. They consist of questions and answers, and documents + which back up that data. + + + + + + + {/* Author Information */} + + + Contributors + +
{skillEditFormData.formData.name}
+
{skillEditFormData.formData.email}
+
+ + ]} + /> +
+ + + + Contribution summary + +
{skillEditFormData.formData.submissionSummary}
+
+ , + + Directory path + +
{skillEditFormData.formData.filePath}
+
+
+ ]} + /> +
+ + {/* Attribution Information */} + {isGithubMode ? ( + + + Title + +
{skillEditFormData.formData.titleWork}
+
+ , + + License of the work + +
{skillEditFormData.formData.licenseWork}
+
+
, + + Authors + +
{skillEditFormData.formData.creators}
+
+
+ ]} + /> +
+ ) : null} + + {/* Seed Examples */} + + + Examples + + + {skillEditFormData.formData.seedExamples?.map((seedExample, index) => ( + + + + ))} + + + + ]} + /> + +
+
+
+ ); +}; + +export default ViewKnowledgeNative; diff --git a/src/components/Contribute/Skill/View/ViewSkillSeedExample.tsx b/src/components/Contribute/Skill/View/ViewSkillSeedExample.tsx new file mode 100644 index 00000000..d1a3df01 --- /dev/null +++ b/src/components/Contribute/Skill/View/ViewSkillSeedExample.tsx @@ -0,0 +1,51 @@ +// src/components/Contribute/Skill/View/ViewSkillSeedExample.tsx +import React from 'react'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionToggle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm +} from '@patternfly/react-core'; +import { t_global_spacer_sm as SmSpacerSize } from '@patternfly/react-tokens/dist/esm/t_global_spacer_sm'; +import { SkillSeedExample } from '@/types'; + +interface Props { + seedExample: SkillSeedExample; + index: number; +} + +const ViewSkillSeedExample: React.FC = ({ seedExample, index }) => { + const [expanded, setExpanded] = React.useState(false); + + return ( + + + setExpanded((prev) => !prev)} id={`seed-example-toggle-${index}`}> + Sample {index + 1} + + + + + Question + {seedExample.questionAndAnswer.question} + + + Answer + {seedExample.questionAndAnswer.answer} + + + Context + {seedExample.context} + + + + + + ); +}; + +export default ViewSkillSeedExample; diff --git a/src/components/Contribute/Skill/View/github/ViewSkillPage.tsx b/src/components/Contribute/Skill/View/github/ViewSkillPage.tsx new file mode 100644 index 00000000..b1c162e8 --- /dev/null +++ b/src/components/Contribute/Skill/View/github/ViewSkillPage.tsx @@ -0,0 +1,77 @@ +// src/app/components/Contribute/Skill/View/github/ViewSkillPage.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { PullRequest, SkillEditFormData } from '@/types'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import { fetchDraftSkillChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchSkillPRData } from '@/components/Contribute/fetchUtils'; +import ViewSkill from '@/components/Contribute/Skill/View/ViewSkill'; +import { fetchPullRequests } from '@/utils/github'; + +interface Props { + branchName: string; + isDraft: boolean; +} + +const ViewSkillPage: React.FC = ({ branchName, isDraft }) => { + const router = useRouter(); + const { data: session } = useSession(); + const { envConfig, loaded } = useEnvConfig(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [skillEditFormData, setSkillEditFormData] = useState(); + + useEffect(() => { + if (isDraft) { + fetchDraftSkillChanges({ branchName, setIsLoading, setLoadingMsg, setSkillEditFormData }); + return; + } + + setLoadingMsg('Fetching knowledge data from PR : ' + branchName); + + const fetchPRData = async () => { + if (!loaded || !session?.accessToken) { + return; + } + + const pullRequests: PullRequest[] = await fetchPullRequests(session.accessToken, envConfig); + const pr = pullRequests.find((pullRequest) => pullRequest.head.ref === branchName); + if (!pr) { + return; + } + + const { editFormData, error } = await fetchSkillPRData(session, envConfig, String(pr.number)); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setSkillEditFormData(editFormData); + }; + fetchPRData(); + }, [session, envConfig, loaded, branchName, isDraft]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading || !skillEditFormData?.formData) { + return ( + handleOnClose()}> + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default ViewSkillPage; diff --git a/src/components/Contribute/Skill/View/native/ViewSkillPage.tsx b/src/components/Contribute/Skill/View/native/ViewSkillPage.tsx new file mode 100644 index 00000000..a7047375 --- /dev/null +++ b/src/components/Contribute/Skill/View/native/ViewSkillPage.tsx @@ -0,0 +1,63 @@ +// src/app/components/Contribute/Skill/View/native/ViewSkillPage.tsx +'use client'; + +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { SkillEditFormData } from '@/types'; +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Modal, ModalVariant, ModalBody } from '@patternfly/react-core'; +import { fetchDraftSkillChanges } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchSkillBranchChanges } from '@/components/Contribute/fetchUtils'; +import ViewSkill from '@/components/Contribute/Skill/View/ViewSkill'; + +interface ViewKnowledgeClientComponentProps { + branchName: string; + isDraft: boolean; +} + +const ViewSkillPage: React.FC = ({ branchName, isDraft }) => { + const router = useRouter(); + const { data: session } = useSession(); + const [isLoading, setIsLoading] = useState(true); + const [loadingMsg, setLoadingMsg] = useState(''); + const [skillEditFormData, setSkillEditFormData] = useState(); + + useEffect(() => { + if (isDraft) { + fetchDraftSkillChanges({ branchName, setIsLoading, setLoadingMsg, setSkillEditFormData }); + return; + } + + setLoadingMsg('Fetching knowledge data from branch : ' + branchName); + const fetchFormData = async () => { + const { editFormData, error } = await fetchSkillBranchChanges(session, branchName); + if (error) { + setLoadingMsg(error); + return; + } + setIsLoading(false); + setSkillEditFormData(editFormData); + }; + fetchFormData(); + }, [branchName, isDraft, session]); + + const handleOnClose = () => { + router.push('/dashboard'); + setIsLoading(false); + }; + + if (isLoading || !skillEditFormData?.formData) { + return ( + handleOnClose()}> + +
{loadingMsg}
+
+
+ ); + } + + return ; +}; + +export default ViewSkillPage; diff --git a/src/components/Contribute/Utils/autoSaveUtils.ts b/src/components/Contribute/Utils/autoSaveUtils.ts index d5829394..50a9af34 100644 --- a/src/components/Contribute/Utils/autoSaveUtils.ts +++ b/src/components/Contribute/Utils/autoSaveUtils.ts @@ -3,9 +3,25 @@ import { KnowledgeSchemaVersion, SkillSchemaVersion } from '@/types/const'; import { devLog } from '@/utils/devlog'; import path from 'path'; -export const storeDraftData = (branchName: string, data: string, isSubmitted: boolean, oldFilesPath: string) => { +export const TOOLTIP_FOR_DISABLE_COMPONENT = 'This action can be performed once the draft changes are either submitted or discarded.'; +export const TOOLTIP_FOR_DISABLE_NEW_COMPONENT = 'This action can be performed once the draft changes are submitted.'; + +export const clearAllDraftData = () => { + const existingDrafts = localStorage.getItem('draftContributions'); + if (existingDrafts !== null && existingDrafts.length !== 0) { + const existingDraftsArr: DraftEditFormInfo[] = JSON.parse(existingDrafts); + existingDraftsArr.forEach((item) => { + if (localStorage.getItem(item.branchName)) { + localStorage.removeItem(item.branchName); + } + }); + localStorage.removeItem('draftContributions'); + } +}; + +export const storeDraftData = (branchName: string, taxonomy: string, data: string, isSubmitted: boolean, oldFilesPath: string) => { localStorage.setItem(branchName, data); - addToDraftList(branchName, isSubmitted, oldFilesPath); + addToDraftList(branchName, isSubmitted, oldFilesPath, taxonomy); }; export const isDraftDataExist = (branchName: string): boolean => { @@ -23,22 +39,23 @@ export const deleteDraftData = (branchName: string) => { const getDraftInfo = (branchName: string): DraftEditFormInfo | undefined => { const existingDrafts = localStorage.getItem('draftContributions'); - if (existingDrafts != null && existingDrafts.length != 0) { + if (existingDrafts !== null && existingDrafts.length !== 0) { const existingDraftsArr: DraftEditFormInfo[] = JSON.parse(existingDrafts); return existingDraftsArr.find((draft) => draft.branchName === branchName); } return undefined; }; -const addToDraftList = (branchName: string, isSubmitted: boolean, oldFilesPath: string) => { +const addToDraftList = (branchName: string, isSubmitted: boolean, oldFilesPath: string, taxonomy: string) => { const existingDrafts = localStorage.getItem('draftContributions'); let draftContributions: DraftEditFormInfo[] = []; const draft: DraftEditFormInfo = { - branchName: branchName, + branchName, lastUpdated: new Date(Date.now()).toUTCString(), isKnowledgeDraft: branchName.includes('knowledge-contribution'), - isSubmitted: isSubmitted, - oldFilesPath: oldFilesPath + isSubmitted, + oldFilesPath, + taxonomy }; if (existingDrafts == null || existingDrafts.length === 0) { draftContributions.push(draft); @@ -85,6 +102,7 @@ export const fetchDraftContributions = (): DraftEditFormInfo[] => { const draftObj: ContributionFormData = JSON.parse(draft); item.author = draftObj.email; item.title = draftObj.submissionSummary; + item.taxonomy = draftObj.filePath; drafts.push(item); } }); diff --git a/src/components/Contribute/Utils/submitUtils.ts b/src/components/Contribute/Utils/submitUtils.ts index 973997b9..9945f553 100644 --- a/src/components/Contribute/Utils/submitUtils.ts +++ b/src/components/Contribute/Utils/submitUtils.ts @@ -1,5 +1,6 @@ import { AttributionData, + EnvConfigType, KnowledgeEditFormData, KnowledgeFormData, KnowledgeYamlData, @@ -11,7 +12,6 @@ import { KnowledgeSchemaVersion, SkillSchemaVersion } from '@/types/const'; import { dumpYaml } from '@/utils/yamlConfig'; import { ActionGroupAlertContent } from '@/components/Contribute/types'; import { amendCommit, getGitHubUsername, updatePullRequest } from '@/utils/github'; -import { fetchEnvConfig } from '@/utils/envConfigService'; import { Session } from 'next-auth'; import { validateKnowledgeFormFields, validateSkillFormFields } from '@/components/Contribute/Utils/validation'; @@ -284,6 +284,7 @@ export const updateNativeKnowledgeData = async ( export const updateGithubKnowledgeData = async ( session: Session | null, + envConfig: EnvConfigType, knowledgeFormData: KnowledgeFormData, knowledgeEditFormData: KnowledgeEditFormData, setActionGroupAlertContent: (content: ActionGroupAlertContent) => void @@ -295,7 +296,7 @@ export const updateGithubKnowledgeData = async ( if (session?.accessToken) { try { console.log(`Updating PR with number: ${knowledgeEditFormData.pullRequestNumber}`); - await updatePullRequest(session.accessToken, knowledgeEditFormData.pullRequestNumber, { + await updatePullRequest(session.accessToken, envConfig, knowledgeEditFormData.pullRequestNumber, { title: knowledgeFormData.submissionSummary }); @@ -375,7 +376,7 @@ Creator names: ${attributionData.creator_names} }; setActionGroupAlertContent(waitForSubmissionAlert); - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const amendedCommitResponse = await amendCommit( session.accessToken, @@ -653,6 +654,7 @@ export const updateNativeSkillData = async ( export const updateGithubSkillData = async ( session: Session | null, + envConfig: EnvConfigType, skillFormData: SkillFormData, skillEditFormData: SkillEditFormData, setActionGroupAlertContent: (content: ActionGroupAlertContent) => void @@ -664,7 +666,7 @@ export const updateGithubSkillData = async ( const { pullRequestNumber, oldFilesPath } = skillEditFormData; try { console.log(`Updating PR with number: ${pullRequestNumber}`); - await updatePullRequest(session.accessToken, pullRequestNumber, { + await updatePullRequest(session.accessToken, envConfig, pullRequestNumber, { title: skillFormData.submissionSummary }); @@ -721,7 +723,7 @@ Creator names: ${attributionData.creator_names} attribution: finalAttributionPath }; - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const waitForSubmissionAlert: ActionGroupAlertContent = { title: 'Skill contribution update is in progress.!', diff --git a/src/components/Contribute/fetchUtils.ts b/src/components/Contribute/fetchUtils.ts new file mode 100644 index 00000000..d56d9e17 --- /dev/null +++ b/src/components/Contribute/fetchUtils.ts @@ -0,0 +1,445 @@ +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Session } from 'next-auth'; +import { ValidatedOptions } from '@patternfly/react-core'; +import { fetchFileContent, fetchPullRequest, fetchPullRequestFiles } from '@/utils/github'; +import { + AttributionData, + EnvConfigType, + KnowledgeEditFormData, + KnowledgeFormData, + KnowledgeYamlData, + PullRequestFile, + QuestionAndAnswerPair, + SkillEditFormData, + SkillFormData, + SkillSeedExample, + SkillYamlData +} from '@/types'; +import { KnowledgeSchemaVersion, SkillSchemaVersion } from '@/types/const'; +import { fetchExistingKnowledgeDocuments } from '@/components/Contribute/Utils/documentUtils'; + +interface ChangeData { + file: string; + status: string; + content?: string; + commitSha?: string; +} + +const parseAttributionContent = (content: string): AttributionData => { + const lines = content.split('\n'); + const attributionData: { [key: string]: string } = {}; + lines.forEach((line) => { + const [key, ...value] = line.split(':'); + if (key && value) { + // Remove spaces in the attribution field for parsing + const normalizedKey = key.trim().toLowerCase().replace(/\s+/g, '_'); + attributionData[normalizedKey] = value.join(':').trim(); + } + }); + return attributionData as unknown as AttributionData; +}; + +const updateKnowledgeFormDataFromYaml = (knowledgeExistingFormData: KnowledgeFormData, yamlData: KnowledgeYamlData) => { + // Populate the form fields with YAML data + knowledgeExistingFormData.knowledgeDocumentRepositoryUrl = yamlData.document.repo; + knowledgeExistingFormData.knowledgeDocumentCommit = yamlData.document.commit; + knowledgeExistingFormData.documentName = yamlData.document.patterns.join(', '); + knowledgeExistingFormData.seedExamples = yamlData.seed_examples.map((seed, index) => ({ + immutable: index < 5, + isExpanded: true, + context: seed.context, + isContextValid: ValidatedOptions.success, + questionAndAnswers: seed.questions_and_answers.map((qa, index) => { + const qna: QuestionAndAnswerPair = { + question: qa.question, + answer: qa.answer, + immutable: index < 3, + isQuestionValid: ValidatedOptions.success, + isAnswerValid: ValidatedOptions.success + }; + return qna; + }) + })); +}; + +const updateKnowledgeFormDataFromAttributionData = (knowledgeExistingFormData: KnowledgeFormData, attributionData: AttributionData) => { + // Populate the form fields with attribution data + knowledgeExistingFormData.titleWork = attributionData.title_of_work; + knowledgeExistingFormData.linkWork = attributionData.link_to_work ? attributionData.link_to_work : ''; + knowledgeExistingFormData.revision = attributionData.revision ? attributionData.revision : ''; + knowledgeExistingFormData.licenseWork = attributionData.license_of_the_work; + knowledgeExistingFormData.creators = attributionData.creator_names; +}; + +export const fetchKnowledgePRData = async ( + session: Session | null, + envConfig: EnvConfigType, + prNumber: string +): Promise<{ editFormData?: KnowledgeEditFormData; error?: string }> => { + if (session?.accessToken) { + try { + const prNum = parseInt(prNumber, 10); + const prData = await fetchPullRequest(session.accessToken, envConfig, prNum); + + // Create KnowledgeFormData from existing form. + const knowledgeExistingFormData: KnowledgeFormData = { + branchName: '', + email: '', + name: '', + submissionSummary: '', + filePath: '', + seedExamples: [], + knowledgeDocumentRepositoryUrl: '', + knowledgeDocumentCommit: '', + documentName: '', + titleWork: '', + linkWork: '', + revision: '', + licenseWork: '', + creators: '', + filesToUpload: [], + uploadedFiles: [] + }; + + const knowledgeEditFormData: KnowledgeEditFormData = { + isEditForm: true, + isSubmitted: true, + version: KnowledgeSchemaVersion, + formData: knowledgeExistingFormData, + pullRequestNumber: prNum, + oldFilesPath: '' + }; + + knowledgeExistingFormData.submissionSummary = prData.title; + knowledgeExistingFormData.branchName = prData.head.ref; // Store the branch name from the pull request + + const prFiles: PullRequestFile[] = await fetchPullRequestFiles(session.accessToken, envConfig, prNum); + + const foundYamlFile = prFiles.find((file: PullRequestFile) => file.filename.endsWith('.yaml')); + if (!foundYamlFile) { + const errorMsg = 'No YAML file found in the pull request.'; + console.error('Error fetching pull request data: ', errorMsg); + return { error: 'Error fetching knowledge data from PR : ' + prNumber + ' [' + errorMsg + ']' + '. Please try again.' }; + } + const existingFilesPath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); + + // Set the current Yaml file path as a old files path + knowledgeEditFormData.oldFilesPath = existingFilesPath + '/'; + + const yamlContent = await fetchFileContent(session.accessToken, envConfig, foundYamlFile.filename, prData.head.sha); + const yamlData: KnowledgeYamlData = yaml.load(yamlContent) as KnowledgeYamlData; + updateKnowledgeFormDataFromYaml(knowledgeExistingFormData, yamlData); + + // Set the file path from the current YAML file (remove the root folder name from the path) + const currentFilePath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); + knowledgeEditFormData.formData.filePath = currentFilePath + '/'; + + // Fetch and parse attribution file if it exists + const foundAttributionFile = prFiles.find((file: PullRequestFile) => file.filename.includes('attribution')); + if (foundAttributionFile) { + const attributionContent = await fetchFileContent(session.accessToken, envConfig, foundAttributionFile.filename, prData.head.sha); + const attributionData = parseAttributionContent(attributionContent); + updateKnowledgeFormDataFromAttributionData(knowledgeExistingFormData, attributionData); + } + const existingFiles = await fetchExistingKnowledgeDocuments(true, knowledgeEditFormData.formData.knowledgeDocumentCommit); + if (existingFiles.length != 0) { + knowledgeExistingFormData.uploadedFiles.push(...existingFiles); + } + return { editFormData: knowledgeEditFormData }; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error('Error fetching pull request data:', error.response ? error.response.data : error.message); + return { error: 'Error fetching knowledge data from PR : ' + prNumber + '. Please try again.' }; + } else if (error instanceof Error) { + console.error('Error fetching pull request data:', error.message); + return { error: 'Error fetching knowledge data from PR : ' + prNumber + ' [' + error.message + ']' + '. Please try again.' }; + } + } + } + return { error: 'Error fetching knowledge data from PR : ' + prNumber + '. Please try again.' }; +}; + +export const fetchKnowledgeBranchChanges = async ( + session: Session | null, + branchName: string +): Promise<{ editFormData?: KnowledgeEditFormData; error?: string }> => { + try { + const response = await fetch('/api/native/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branchName, action: 'diff' }) + }); + + const result = await response.json(); + if (response.ok) { + // Create KnowledgeFormData from existing form. + const knowledgeExistingFormData: KnowledgeFormData = { + branchName: branchName, + email: '', + name: '', + submissionSummary: '', + filePath: '', + seedExamples: [], + knowledgeDocumentRepositoryUrl: '', + knowledgeDocumentCommit: '', + documentName: '', + titleWork: '', + linkWork: '', + revision: '', + licenseWork: '', + creators: '', + filesToUpload: [], + uploadedFiles: [] + }; + + const knowledgeEditFormData: KnowledgeEditFormData = { + isEditForm: true, + isSubmitted: true, + version: KnowledgeSchemaVersion, + formData: knowledgeExistingFormData, + pullRequestNumber: 0, + oldFilesPath: '' + }; + + if (session?.user?.name && session?.user?.email) { + knowledgeExistingFormData.name = session?.user?.name; + knowledgeExistingFormData.email = session?.user?.email; + } + + if (result?.commitDetails != null) { + knowledgeExistingFormData.submissionSummary = result?.commitDetails.message; + knowledgeExistingFormData.name = result?.commitDetails.name; + knowledgeExistingFormData.email = result?.commitDetails.email; + } + + if (result?.changes.length > 0) { + result.changes.forEach((change: ChangeData) => { + if (change.status != 'deleted' && change.content) { + if (change.file.includes('qna.yaml')) { + const yamlData: KnowledgeYamlData = yaml.load(change.content) as KnowledgeYamlData; + updateKnowledgeFormDataFromYaml(knowledgeExistingFormData, yamlData); + + // Set the file path from the current YAML file (remove the root folder name from the path) + const currentFilePath = change.file.split('/').slice(1, -1).join('/'); + knowledgeExistingFormData.filePath = currentFilePath + '/'; + + // Set the oldFilesPath to the existing qna.yaml file path. + knowledgeEditFormData.oldFilesPath = knowledgeExistingFormData.filePath; + } + if (change.file.includes('attribution.txt')) { + const attributionData = parseAttributionContent(change.content); + updateKnowledgeFormDataFromAttributionData(knowledgeExistingFormData, attributionData); + } + } + }); + const existingFiles = await fetchExistingKnowledgeDocuments(false, knowledgeEditFormData.formData.knowledgeDocumentCommit); + if (existingFiles.length != 0) { + console.log(`Contribution has ${existingFiles.length} existing knowledge documents`); + knowledgeExistingFormData.uploadedFiles.push(...existingFiles); + } + return { editFormData: knowledgeEditFormData }; + } + } + } catch (error) { + console.error('Error fetching branch changes:', error); + } + + return { error: 'Error fetching knowledge data from branch : ' + branchName + '. Please try again.' }; +}; + +export const fetchSkillPRData = async ( + session: Session | null, + envConfig: EnvConfigType, + prNumber: string +): Promise<{ editFormData?: SkillEditFormData; error?: string }> => { + if (session?.accessToken) { + try { + const prNum = parseInt(prNumber, 10); + const prData = await fetchPullRequest(session.accessToken, envConfig, prNum); + + const skillExistingFormData: SkillFormData = { + branchName: '', + email: '', + name: '', + submissionSummary: '', + filePath: '', + seedExamples: [], + titleWork: '', + licenseWork: '', + creators: '' + }; + + const skillEditFormData: SkillEditFormData = { + isEditForm: true, + isSubmitted: true, + version: SkillSchemaVersion, + formData: skillExistingFormData, + pullRequestNumber: prNum, + oldFilesPath: '' + }; + + skillExistingFormData.branchName = prData.head.ref; // Store the branch name from the pull request + + const prFiles: PullRequestFile[] = await fetchPullRequestFiles(session.accessToken, envConfig, prNum); + + const foundYamlFile = prFiles.find((file: PullRequestFile) => file.filename.endsWith('.yaml')); + if (!foundYamlFile) { + const errorMsg = 'No YAML file found in the pull request.'; + console.error('Error fetching pull request data:', errorMsg); + return { error: 'Error fetching skills data from PR: ' + prNumber + ' [' + errorMsg + ']. Please try again.' }; + } + + const existingFilesPath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); + + // Set the current Yaml file path as a old files path + skillEditFormData.oldFilesPath = existingFilesPath + '/'; + + const yamlContent = await fetchFileContent(session.accessToken, envConfig, foundYamlFile.filename, prData.head.sha); + const yamlData: SkillYamlData = yaml.load(yamlContent) as SkillYamlData; + console.log('Parsed YAML data:', yamlData); + + // Populate the form fields with YAML data + skillExistingFormData.submissionSummary = yamlData.task_description; + + skillExistingFormData.seedExamples = yamlData.seed_examples.map((seed, index) => ({ + immutable: index < 5, + isExpanded: true, + context: seed.context, + isContextValid: ValidatedOptions.success, + questionAndAnswer: { + immutable: index < 5, + question: seed.question, + isQuestionValid: ValidatedOptions.success, + answer: seed.answer, + isAnswerValid: ValidatedOptions.success + } + })); + + // Set the file path from the current YAML file + const currentFilePath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); + skillEditFormData.formData.filePath = currentFilePath + '/'; + + // Fetch and parse attribution file if it exists + const foundAttributionFile = prFiles.find((file: PullRequestFile) => file.filename.includes('attribution')); + if (foundAttributionFile) { + const attributionContent = await fetchFileContent(session.accessToken, envConfig, foundAttributionFile.filename, prData.head.sha); + const attributionData = parseAttributionContent(attributionContent); + console.log('Parsed attribution data:', attributionData); + + // Populate the form fields with attribution data + skillExistingFormData.titleWork = attributionData.title_of_work; + skillExistingFormData.licenseWork = attributionData.license_of_the_work; + skillExistingFormData.creators = attributionData.creator_names; + } + return { editFormData: skillEditFormData }; + } catch (error) { + if (axios.isAxiosError(error)) { + console.error('Error fetching pull request data:', error.response ? error.response.data : error.message); + return { error: 'Error fetching knowledge data from PR : ' + prNumber + '. Please try again.' }; + } else if (error instanceof Error) { + console.error('Error fetching pull request data:', error.message); + return { error: 'Error fetching skills data from PR: ' + prNumber + ' [' + error.message + ']. Please try again.' }; + } + } + } + return { error: 'Error fetching knowledge data from PR : ' + prNumber + '. Please try again.' }; +}; + +export const fetchSkillBranchChanges = async ( + session: Session | null, + branchName: string +): Promise<{ editFormData?: SkillEditFormData; error?: string }> => { + try { + const response = await fetch('/api/native/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branchName, action: 'diff' }) + }); + + const result = await response.json(); + if (response.ok) { + const skillExistingFormData: SkillFormData = { + branchName: branchName, + email: '', + name: '', + submissionSummary: '', + filePath: '', + seedExamples: [], + titleWork: '', + licenseWork: '', + creators: '' + }; + + const skillEditFormData: SkillEditFormData = { + isEditForm: true, + isSubmitted: true, + version: SkillSchemaVersion, + pullRequestNumber: 0, + formData: skillExistingFormData, + oldFilesPath: '' + }; + + if (session?.user?.name && session?.user?.email) { + skillExistingFormData.name = session?.user?.name; + skillExistingFormData.email = session?.user?.email; + } + + if (result?.commitDetails != null) { + skillExistingFormData.submissionSummary = result?.commitDetails.message; + skillExistingFormData.name = result?.commitDetails.name; + skillExistingFormData.email = result?.commitDetails.email; + } + + if (result?.changes.length > 0) { + result.changes.forEach((change: ChangeData) => { + if (change.status != 'deleted' && change.content) { + if (change.file.includes('qna.yaml')) { + const yamlData: SkillYamlData = yaml.load(change.content) as SkillYamlData; + console.log('Parsed skill YAML data:', yamlData); + skillExistingFormData.submissionSummary = yamlData.task_description; + const seedExamples: SkillSeedExample[] = []; + yamlData.seed_examples.forEach((seed, index) => { + const example: SkillSeedExample = { + immutable: index < 5, + isExpanded: true, + context: seed.context, + isContextValid: ValidatedOptions.success, + questionAndAnswer: { + immutable: index < 5, + question: seed.question, + isQuestionValid: ValidatedOptions.success, + answer: seed.answer, + isAnswerValid: ValidatedOptions.success + } + }; + seedExamples.push(example); + }); + skillExistingFormData.seedExamples = seedExamples; + + //Extract filePath from the existing qna.yaml file path + const currentFilePath = change.file.split('/').slice(1, -1).join('/'); + skillEditFormData.formData.filePath = currentFilePath + '/'; + + // Set the oldFilesPath to the existing qna.yaml file path. + skillEditFormData.oldFilesPath = skillEditFormData.formData.filePath; + } + if (change.file.includes('attribution.txt')) { + const attributionData = parseAttributionContent(change.content); + // Populate the form fields with attribution data + skillExistingFormData.titleWork = attributionData.title_of_work; + skillExistingFormData.licenseWork = attributionData.license_of_the_work; + skillExistingFormData.creators = attributionData.creator_names; + } + } + }); + } + return { editFormData: skillEditFormData }; + } else { + console.error('Failed to get branch changes:', result.error); + } + } catch (error) { + console.error('Error fetching branch changes:', error); + } + return { error: 'Error fetching knowledge data from branch : ' + branchName + '. Please try again.' }; +}; diff --git a/src/components/Dashboard/ContributionActions.tsx b/src/components/Dashboard/ContributionActions.tsx new file mode 100644 index 00000000..1b38239c --- /dev/null +++ b/src/components/Dashboard/ContributionActions.tsx @@ -0,0 +1,259 @@ +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { + Button, + Divider, + Dropdown, + DropdownItem, + DropdownList, + Flex, + MenuToggle, + MenuToggleElement, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Spinner, + Tooltip +} from '@patternfly/react-core'; +import { EllipsisVIcon } from '@patternfly/react-icons'; +import { ContributionInfo } from '@/types'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import { handleTaxonomyDownload } from '@/utils/taxonomy'; +import { deleteDraftData, TOOLTIP_FOR_DISABLE_COMPONENT, TOOLTIP_FOR_DISABLE_NEW_COMPONENT } from '@/components/Contribute/Utils/autoSaveUtils'; +import ContributionChangesModal from '@/components/Dashboard/ContributionChangesModal'; +import DeleteContributionModal from '@/components/Dashboard/DeleteContributionModal'; + +interface Props { + contribution: ContributionInfo; + onUpdateContributions: () => void; + addAlert: (message: string, status: 'success' | 'danger') => void; +} + +const ContributionActions: React.FC = ({ contribution, onUpdateContributions, addAlert }) => { + const router = useRouter(); + const { + envConfig: { isGithubMode, taxonomyRootDir } + } = useEnvConfig(); + const [isActionMenuOpen, setIsActionMenuOpen] = React.useState(false); + const [isChangeModalOpen, setIsChangeModalOpen] = React.useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); + const [isPublishModalOpen, setIsPublishModalOpen] = React.useState(false); + const [isPublishing, setIsPublishing] = React.useState(false); + const [isDownloadOpen, setIsDownloadOpen] = React.useState(false); + const changesRef = React.useRef(null); + const editRef = React.useRef(null); + const publishRef = React.useRef(null); + const downloadRef = React.useRef(null); + const deleteRef = React.useRef(null); + + const handleEditContribution = () => { + router.push( + `/contribute/${contribution.isKnowledge ? 'knowledge' : 'skill'}/edit/${isGithubMode ? 'github' : 'native'}/${contribution.branchName}${contribution.isDraft ? '/isDraft' : ''}` + ); + }; + + const deleteContribution = async (branchName: string) => { + try { + const response = await fetch('/api/native/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branchName, action: 'delete' }) + }); + + const result = await response.json(); + if (response.ok) { + // Remove the branch from the list + onUpdateContributions(); + addAlert(result.message, 'success'); + } else { + console.error(result.error); + addAlert(result.error, 'danger'); + } + } catch (error) { + if (error instanceof Error) { + const errorMessage = 'Error deleting branch ' + branchName + ':' + error.message; + console.error(errorMessage); + addAlert(errorMessage, 'danger'); + } else { + console.error('Unknown error deleting the contribution ${branchName}'); + addAlert('Unknown error deleting the contribution ${branchName}', 'danger'); + } + } + }; + + const handleDeleteContributionConfirm = async (doDelete: boolean) => { + if (doDelete) { + if (contribution.isDraft) { + deleteDraftData(contribution.branchName); + } else { + await deleteContribution(contribution.branchName); + } + onUpdateContributions(); + } + setIsDeleteModalOpen(false); + }; + + const handlePublishContribution = async () => { + setIsPublishing(true); + try { + const response = await fetch('/api/native/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branchName: contribution.branchName, action: 'publish' }) + }); + + const result = await response.json(); + if (response.ok) { + setIsPublishing(false); + addAlert(result.message || 'Successfully published contribution.', 'success'); + setIsPublishModalOpen(false); + } else { + console.error('Failed to publish the contribution:', result.error); + addAlert(result.error || 'Failed to publish the contribution.', 'danger'); + } + } catch (error) { + console.error('Error while publishing the contribution:', error); + addAlert(`Error while publishing the contribution: ${error}`, 'danger'); + } + setIsPublishing(false); + setIsPublishModalOpen(false); + }; + + const submissionDisabledTooltip = (triggerRef: React.RefObject) => ( + + ); + + return ( + <> + setIsActionMenuOpen(false)} + toggle={(toggleRef: React.Ref) => ( + setIsActionMenuOpen((prev) => !prev)} + variant="plain" + aria-label="contribution action menu" + icon={ + {isChangeModalOpen ? setIsChangeModalOpen(false)} /> : null} + {isDeleteModalOpen ? : null} + {isPublishModalOpen ? ( + setIsPublishModalOpen(false)} + aria-labelledby="publish-contribution-modal-title" + aria-describedby="publish-contribution-body-variant" + > + + +

Are you sure you want to publish contribution to remote taxonomy repository present at : {taxonomyRootDir}?

+
+ + + + +
+ ) : null} + {isDownloadOpen ? ( + setIsDownloadOpen(false)}> + + + + Retrieving the taxonomy compressed file with the contributed data. + + + + ) : null} + + ); +}; + +export default ContributionActions; diff --git a/src/components/Dashboard/ContributionCard.tsx b/src/components/Dashboard/ContributionCard.tsx new file mode 100644 index 00000000..f08f0877 --- /dev/null +++ b/src/components/Dashboard/ContributionCard.tsx @@ -0,0 +1,118 @@ +// src/components/Dashboard/ContributionCard.tsx +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { + Card, + CardBody, + Flex, + FlexItem, + CardHeader, + CardTitle, + Button, + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + GalleryItem +} from '@patternfly/react-core'; +import { t_global_color_disabled_100 as DisabledColor } from '@patternfly/react-tokens'; +import { ContributionInfo } from '@/types'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import TruncatedText from '@/components/Common/TruncatedText'; +import TableRowTitleDescription from '@/components/Table/TableRowTitleDescription'; +import { getTaxonomyDir, getFormattedLastUpdatedDate } from '@/components/Dashboard/const'; +import ContributionActions from '@/components/Dashboard/ContributionActions'; +import ContributionStatus from '@/components/Dashboard/ContributionStatus'; +import { NewContributionLabel, KnowledgeContributionLabel, SkillContributionLabel } from '@/components/Contribute/ContributionLabels'; + +interface Props { + contribution: ContributionInfo; + onUpdateContributions: () => void; + addAlert: (message: string, status: 'success' | 'danger') => void; +} + +const ContributionCard: React.FC = ({ contribution, onUpdateContributions, addAlert }) => { + const router = useRouter(); + const { + envConfig: { isGithubMode } + } = useEnvConfig(); + + return ( + + + + + + }} + > + {contribution.isKnowledge ? : } + + + + + + + + + {!contribution.isSubmitted ? ( + + + + ) : null} + + } + /> + + + + + + + + + Taxonomy + + {contribution.taxonomy ? ( + getTaxonomyDir(contribution.taxonomy) + ) : ( + Not defined + )} + + + + Status + + + + + + Last updated + {getFormattedLastUpdatedDate(contribution.lastUpdated)} + + + + + + +
+ + + ); +}; + +export default ContributionCard; diff --git a/src/components/Dashboard/ContributionChangesModal.tsx b/src/components/Dashboard/ContributionChangesModal.tsx new file mode 100644 index 00000000..4f856ce3 --- /dev/null +++ b/src/components/Dashboard/ContributionChangesModal.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { Flex, Modal, ModalBody, ModalHeader, ModalVariant, Spinner } from '@patternfly/react-core'; +import { ExpandableSection } from '@patternfly/react-core/dist/esm/components/ExpandableSection/ExpandableSection'; +import { t_global_spacer_xl as XlSpacerSize } from '@patternfly/react-tokens/dist/esm/t_global_spacer_xl'; +import { ContributionInfo } from '@/types'; + +interface ChangeData { + file: string; + status: string; + content?: string; + commitSha?: string; +} + +interface Props { + contribution: ContributionInfo; + onClose: () => void; +} + +const ContributionChangesModel: React.FC = ({ contribution, onClose }) => { + const [diffData, setDiffData] = React.useState(null); + const [expandedFiles, setExpandedFiles] = React.useState>({}); + const [errorMsg, setErrorMsg] = React.useState(); + + React.useEffect(() => { + const fetchChangeData = async () => { + try { + const response = await fetch('/api/native/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branchName: contribution.branchName, action: 'diff' }) + }); + + const result = await response.json(); + if (response.ok) { + setDiffData(result.changes); + } else { + console.error('Failed to get branch changes:', result.error); + setErrorMsg(result.error); + } + } catch (error) { + console.error('Error fetching branch changes:', error); + setErrorMsg((error as Error).message); + } + }; + fetchChangeData(); + }, [contribution.branchName]); + + const toggleFileContent = (filename: string) => { + setExpandedFiles((prev) => ({ + ...prev, + [filename]: !prev[filename] + })); + }; + + return ( + onClose()} + aria-labelledby="changes-contribution-modal-title" + > + + + {errorMsg ? ( + errorMsg + ) : !diffData ? ( + + + + ) : ( + <> + {diffData.length ? ( +
    + {diffData.map((change) => ( +
  • +
    + {change.file} - {change.status} - Commit SHA: {change.commitSha} +
    + {change.status !== 'deleted' && change.content && ( + toggleFileContent(change.file)} + isExpanded={expandedFiles[change.file] || false} + > +
    +                          {change.content}
    +                        
    +
    + )} +
  • + ))} +
+ ) : ( +

No differences found.

+ )} + + )} +
+
+ ); +}; + +export default ContributionChangesModel; diff --git a/src/components/Dashboard/ContributionStatus.tsx b/src/components/Dashboard/ContributionStatus.tsx new file mode 100644 index 00000000..dac92439 --- /dev/null +++ b/src/components/Dashboard/ContributionStatus.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { ContributionInfo } from '@/types'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { CheckCircleIcon, PficonTemplateIcon } from '@patternfly/react-icons'; +import { t_global_icon_color_status_success_default as SuccessColor } from '@patternfly/react-tokens/dist/esm/t_global_icon_color_status_success_default'; + +interface Props { + contribution: ContributionInfo; +} + +const ContributionStatus: React.FC = ({ contribution }) => ( + + {contribution.isDraft ? ( + <> + + Draft + + ) : ( + <> + + Submitted + + )} + +); + +export default ContributionStatus; diff --git a/src/components/Dashboard/ContributionTableRow.tsx b/src/components/Dashboard/ContributionTableRow.tsx new file mode 100644 index 00000000..c89414e7 --- /dev/null +++ b/src/components/Dashboard/ContributionTableRow.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { Button, Flex, FlexItem } from '@patternfly/react-core'; +import { Tr, Td } from '@patternfly/react-table'; +import { t_global_color_disabled_100 as DisabledColor } from '@patternfly/react-tokens/dist/esm/t_global_color_disabled_100'; +import { ContributionInfo } from '@/types'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import TruncatedText from '@/components/Common/TruncatedText'; +import TableRowTitleDescription from '@/components/Table/TableRowTitleDescription'; +import { getTaxonomyDir, getFormattedLastUpdatedDate } from '@/components/Dashboard/const'; +import ContributionActions from '@/components/Dashboard/ContributionActions'; +import ContributionStatus from '@/components/Dashboard/ContributionStatus'; +import { NewContributionLabel, KnowledgeContributionLabel, SkillContributionLabel } from '@/components/Contribute/ContributionLabels'; + +interface Props { + contribution: ContributionInfo; + onUpdateContributions: () => void; + addAlert: (message: string, status: 'success' | 'danger') => void; +} + +const ContributionTableRow: React.FC = ({ contribution, onUpdateContributions, addAlert }) => { + const router = useRouter(); + const { + envConfig: { isGithubMode } + } = useEnvConfig(); + + return ( + + + + + + + {!contribution.isSubmitted ? ( + + + + ) : null} +
+ } + /> + + {contribution.isKnowledge ? : } + + {contribution.taxonomy ? getTaxonomyDir(contribution.taxonomy) : Not defined} + + + + + {getFormattedLastUpdatedDate(contribution.lastUpdated)} + + + + + ); +}; + +export default ContributionTableRow; diff --git a/src/components/Dashboard/Dashboard.scss b/src/components/Dashboard/Dashboard.scss new file mode 100644 index 00000000..5b6efcf7 --- /dev/null +++ b/src/components/Dashboard/Dashboard.scss @@ -0,0 +1,35 @@ +.dashboard-page { + .pf-v6-c-page__main { + overflow-y: hidden; + } + .pf-v6-c-page__main-section.pf-m-fill { + flex: 1; + overflow-y: hidden; + .pf-v6-c-page__main-body { + height: 100%; + overflow-y: hidden; + } + } + .contributions-table { + .pf-v6-c-table__th { + padding-block-start: 0; + } + } + .contribution-card { + height: 100%; + + &__body { + height: 100%; + } + + .pf-v6-c-card__title { + padding-block-start: 0 !important; + padding-block-end: 0 !important; + } + } +} +.destructive-action-item { + .pf-v6-c-menu__item-text { + color: var(--pf-t--global--color--status--danger--100); + } +} diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx new file mode 100644 index 00000000..1570adbf --- /dev/null +++ b/src/components/Dashboard/Dashboard.tsx @@ -0,0 +1,318 @@ +// src/components/Dashboard/Dashboard.tsx +import * as React from 'react'; +import Image from 'next/image'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { + AlertProps, + PageSection, + Title, + Content, + Button, + AlertGroup, + Alert, + AlertVariant, + AlertActionCloseButton, + Spinner, + EmptyState, + EmptyStateBody, + EmptyStateFooter, + EmptyStateActions, + Flex, + FlexItem, + Bullseye, + Dropdown, + MenuToggleElement, + DropdownList, + DropdownItem +} from '@patternfly/react-core'; +import { AngleDownIcon, GithubIcon, SearchIcon } from '@patternfly/react-icons'; +import { ContributionInfo } from '@/types'; +import { useFeatureFlags } from '@/context/FeatureFlagsContext'; +import Table from '@/components/Table/Table'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; +import ClearDraftDataButton from '@/components/Contribute/ClearDraftDataButton'; +import { + ContributionColumns, + ContributionSorter, + SORT_ASCENDING, + SORT_BY_LAST_UPDATE, + SORT_DESCENDING, + SortByIndex +} from '@/components/Dashboard/const'; +import DashboardToolbar from '@/components/Dashboard/DashboardToolbar'; +import ContributionTableRow from '@/components/Dashboard/ContributionTableRow'; +import ContributionCard from '@/components/Dashboard/ContributionCard'; + +import './Dashboard.scss'; +import CardView from '@/components/CardView/CardView'; + +const InstructLabLogo: React.FC = () => InstructLab Logo; + +export interface AlertItem { + title: string; + variant: AlertProps['variant']; + key: React.Key; +} + +interface Props { + contributions: ContributionInfo[]; + isLoading: boolean; + triggerUpdateContributions: () => void; + alerts: AlertItem[]; + addAlert: (title: string, variant: AlertProps['variant']) => void; + removeAlert: (alert: AlertItem) => void; +} + +const helpLinkUrl = `https://docs.instructlab.ai/user-interface/ui_overview`; + +const Dashboard: React.FC = ({ contributions, isLoading, triggerUpdateContributions, alerts, addAlert, removeAlert }) => { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { + featureFlags: { skillFeaturesEnabled } + } = useFeatureFlags(); + const { + envConfig: { isDevMode } + } = useEnvConfig(); + const [isActionsOpen, setIsActionsOpen] = React.useState(false); + const [filter, setFilter] = React.useState(''); + + const viewType = searchParams.get('viewType') || 'table'; + const sort = searchParams.get('sortBy') || SORT_BY_LAST_UPDATE; + const sortDirParam = searchParams.get('sortDir'); + const sortDirection = sortDirParam === SORT_ASCENDING || sortDirParam === SORT_DESCENDING ? sortDirParam : SORT_ASCENDING; + + const filteredContributions = React.useMemo( + () => + contributions + .filter((contribution) => skillFeaturesEnabled || contribution.isKnowledge) + .filter((contribution) => !filter || contribution.title.toLowerCase().includes(filter.toLowerCase())) + .sort(ContributionSorter(sort, sortDirection)), + [contributions, sort, sortDirection, skillFeaturesEnabled, filter] + ); + + const setQueryParam = React.useCallback( + (name: string, value: string, name2?: string, value2?: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set(name, value); + if (name2 && value2) { + params.set(name2, value2); + } + + router.push(pathname + '?' + params.toString()); + }, + [pathname, router, searchParams] + ); + + const setCurrentSortAndDirection = React.useCallback( + (newSort: number, newDir: string) => { + setQueryParam('sortBy', SortByIndex[newSort] || SORT_BY_LAST_UPDATE, 'sortDir', newDir); + }, + [setQueryParam] + ); + + const toolbar = ( + setQueryParam('viewType', isTableView ? 'table' : 'card')} + currentSort={sort} + setCurrentSort={(newSort) => setQueryParam('sortBy', newSort)} + currentSortDirection={sortDirection} + setCurrentSortDirection={(newDir) => setQueryParam('sortDir', newDir)} + /> + ); + + const filteredEmptyView = ( + + + No matching contributions found + + + + + + ); + + return ( + + + + + + + + + My contributions + + + + {skillFeaturesEnabled ? ( + setIsActionsOpen(false)} + onOpenChange={(isOpen: boolean) => setIsActionsOpen(isOpen)} + toggle={(toggleRef: React.Ref) => ( + + )} + shouldFocusToggleOnSelect + > + + router.push('/contribute/knowledge/')}>Contribute knowledge + router.push('/contribute/skill/')}>Contribute skills + + + ) : ( + + )} + {isDevMode ? : null} + + + + + + View and manage your contributions. By contributing your own data, you can help train and refine your language models. + + + + + + + {isLoading ? ( + + + + ) : contributions.length === 0 ? ( + + +
+ InstructLab is a powerful and accessible tool for advancing generative AI through community collaboration and open-source + principles. By contributing your own data, you can help train and refine the language model.
+
+ To get started, contribute {skillFeaturesEnabled ? 'a skill or contribute ' : ''}knowledge. +
+
+ + + {skillFeaturesEnabled ? ( + + ) : null} + + + + + + + +
+ ) : viewType === 'table' ? ( + ( + + )} + /> + ) : ( + ( + + )} + /> + )} + + + + {alerts.map((alert) => ( + removeAlert(alert)} />} + key={alert.key} + /> + ))} + + + ); +}; + +export default Dashboard; diff --git a/src/components/Dashboard/DashboardToolbar.tsx b/src/components/Dashboard/DashboardToolbar.tsx new file mode 100644 index 00000000..9ff0078e --- /dev/null +++ b/src/components/Dashboard/DashboardToolbar.tsx @@ -0,0 +1,125 @@ +import * as React from 'react'; +import { + Button, + Flex, + FlexItem, + MenuToggle, + SearchInput, + Select, + SelectList, + SelectOption, + ToggleGroup, + ToggleGroupItem, + Toolbar, + ToolbarGroup, + ToolbarItem +} from '@patternfly/react-core'; +import { PficonSortCommonAscIcon, PficonSortCommonDescIcon, TableIcon, ThLargeIcon } from '@patternfly/react-icons'; +import { + SORT_ASCENDING, + SORT_BY_LAST_UPDATE, + SORT_BY_STATUS, + SORT_BY_TAXONOMY, + SORT_BY_TITLE, + SORT_BY_TYPE, + SORT_DESCENDING, + SortTitles +} from '@/components/Dashboard/const'; + +interface Props { + currentFilter?: string; + onFilterUpdate: (value?: string) => void; + isTableView: boolean; + setIsTableView: (value: boolean) => void; + currentSort: string; + setCurrentSort: (newSort: string) => void; + currentSortDirection: string; + setCurrentSortDirection: (newSort: string) => void; +} + +const DashboardToolbar: React.FC = ({ + currentFilter = '', + onFilterUpdate, + isTableView, + setIsTableView, + currentSort, + setCurrentSort, + currentSortDirection, + setCurrentSortDirection +}) => { + const [isSortValueOpen, setIsSortValueOpen] = React.useState(false); + + return ( + + + + onFilterUpdate(value)} + /> + + + + } aria-label="table view" isSelected={isTableView} onChange={() => setIsTableView(true)} /> + } aria-label="table view" isSelected={!isTableView} onChange={() => setIsTableView(false)} /> + + + {!isTableView ? ( + <> + + + + + + + + + + + ); +}; + +export default DeleteContributionModal; diff --git a/src/components/Dashboard/Github/DashboardPage.tsx b/src/components/Dashboard/Github/DashboardPage.tsx new file mode 100644 index 00000000..9dd50e79 --- /dev/null +++ b/src/components/Dashboard/Github/DashboardPage.tsx @@ -0,0 +1,142 @@ +// src/components/Dashboard/Github/DashboardPage.tsx +import * as React from 'react'; +import { useSession } from 'next-auth/react'; +import { v4 as uuidv4 } from 'uuid'; +import { ContributionInfo, DraftEditFormInfo, EnvConfigType, PullRequest, PullRequestFile } from '@/types'; +import { useState } from 'react'; +import { AlertProps } from '@patternfly/react-core'; +import { useEnvConfig } from '@/context/EnvConfigContext'; +import { fetchDraftContributions } from '@/components/Contribute/Utils/autoSaveUtils'; +import { fetchPullRequestFiles, fetchPullRequests, getGitHubUsername } from '@/utils/github'; +import Dashboard, { AlertItem } from '@/components/Dashboard/Dashboard'; + +const fetchPrTaxonomy = async (accessToken: string, envConfig: EnvConfigType, prNumber: number) => { + try { + const prFiles: PullRequestFile[] = await fetchPullRequestFiles(accessToken, envConfig, prNumber); + + const foundYamlFile = prFiles.find((file: PullRequestFile) => file.filename.includes('qna.yaml')); + if (foundYamlFile) { + const currentFilePath = foundYamlFile.filename.split('/').slice(1, -1).join('/'); + return currentFilePath + '/'; + } + + const errorMsg = 'No YAML file found in the pull request.'; + console.error('Error fetching pull request data: ', errorMsg); + } catch (error) { + console.error('Error fetching branch changes:', error); + } + + return ''; +}; + +const DashboardGithub: React.FunctionComponent = () => { + const { data: session } = useSession(); + const { envConfig } = useEnvConfig(); + const [pullRequests, setPullRequests] = React.useState([]); + const [draftContributions, setDraftContributions] = React.useState([]); + const [isLoading, setIsLoading] = useState(true); + const [alerts, setAlerts] = React.useState([]); + + const addAlert = React.useCallback((title: string, variant: AlertProps['variant']) => { + const alertKey = uuidv4(); + const newAlert: AlertItem = { title, variant, key: alertKey }; + setAlerts((prevAlerts) => [...prevAlerts, newAlert]); + }, []); + + const removeAlert = (alertToRemove: AlertItem) => { + setAlerts((prevAlerts) => prevAlerts.filter((alert) => alert.key !== alertToRemove.key)); + }; + + const fetchAndSetPullRequests = React.useCallback(async () => { + if (session?.accessToken) { + try { + const header = { + Authorization: `Bearer ${session.accessToken}`, + Accept: 'application/vnd.github.v3+json' + }; + const fetchedUsername = await getGitHubUsername(header); + const data = await fetchPullRequests(session.accessToken, envConfig); + const filteredPRs = data.filter( + (pr: PullRequest) => pr.user.login === fetchedUsername && pr.labels.some((label) => label.name === 'skill' || label.name === 'knowledge') + ); + for (const pr of filteredPRs) { + pr.taxonomy = await fetchPrTaxonomy(session.accessToken, envConfig, pr.number); + } + + // Sort by date (newest first) + const sortedPRs = filteredPRs.sort((a: PullRequest, b: PullRequest) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + setPullRequests(sortedPRs); + } catch (error) { + console.error('Failed to fetch pull requests.' + error); + addAlert('Error fetching pull requests.', 'danger'); + } + } + }, [addAlert, session?.accessToken, envConfig]); + + React.useEffect(() => { + fetchAndSetPullRequests().then(() => { + setIsLoading(false); + }); + + const intervalId = setInterval(fetchAndSetPullRequests, 60000); + + return () => clearInterval(intervalId); + }, [fetchAndSetPullRequests]); + + React.useEffect(() => { + // Fetch all the draft contributions and mark them submitted if present in the pull requests + const drafts = fetchDraftContributions().map((draft: DraftEditFormInfo) => ({ + ...draft, + isSubmitted: pullRequests.some((pr) => pr.head.ref === draft.branchName) + })); + + setDraftContributions(drafts); + }, [pullRequests]); + + const contributions: ContributionInfo[] = React.useMemo( + () => [ + ...draftContributions + .filter((draft) => !pullRequests.find((pr) => pr.head.ref === draft.branchName)) + .map((draft, index) => ({ + branchName: draft.branchName, + title: draft.title || `Untitled ${draft.isKnowledgeDraft ? 'knowledge' : 'skill'} contribution ${index + 1}`, + author: draft.author, + lastUpdated: new Date(draft.lastUpdated), + isDraft: true, + isKnowledge: draft.isKnowledgeDraft, + isSubmitted: draft.isSubmitted, + state: 'draft', + taxonomy: draft.taxonomy + })), + ...pullRequests.map((pr) => ({ + branchName: `${pr.head.ref}`, + title: pr.title, + author: '', + lastUpdated: new Date(pr.updated_at), + isDraft: !!draftContributions.find((draft) => draft.branchName == pr.head.ref), + isKnowledge: pr.labels.some((label) => label.name === 'knowledge'), + isSubmitted: true, + state: 'Available', + taxonomy: pr.taxonomy + })) + ], + [pullRequests, draftContributions] + ); + + const onUpdateContributions = () => { + fetchAndSetPullRequests(); + }; + + return ( + + ); +}; + +export { DashboardGithub }; diff --git a/src/components/Dashboard/Github/dashboard.tsx b/src/components/Dashboard/Github/dashboard.tsx deleted file mode 100644 index bc0671b5..00000000 --- a/src/components/Dashboard/Github/dashboard.tsx +++ /dev/null @@ -1,402 +0,0 @@ -// src/components/dashboard/github/dashboard.tsx -import * as React from 'react'; -import { useSession } from 'next-auth/react'; -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { DraftEditFormInfo, PullRequest } from '@/types'; -import { useState } from 'react'; -import { - PageSection, - Title, - Content, - Popover, - Button, - Modal, - ModalVariant, - Spinner, - EmptyState, - EmptyStateBody, - EmptyStateFooter, - EmptyStateActions, - Card, - CardTitle, - CardBody, - Flex, - FlexItem, - Label, - ModalBody, - CardHeader, - Dropdown, - MenuToggle, - DropdownList, - DropdownItem, - MenuToggleElement, - Gallery, - GalleryItem -} from '@patternfly/react-core'; -import { ExternalLinkAltIcon, OutlinedQuestionCircleIcon, GithubIcon, EllipsisVIcon, PficonTemplateIcon } from '@patternfly/react-icons'; -import { deleteDraftData, fetchDraftContributions } from '@/components/Contribute/Utils/autoSaveUtils'; -import { handleTaxonomyDownload } from '@/utils/taxonomy'; -import { fetchPullRequests, getGitHubUsername } from '@/utils/github'; - -const InstructLabLogo: React.FC = () => ; - -const DashboardGithub: React.FunctionComponent = () => { - const { data: session } = useSession(); - const [pullRequests, setPullRequests] = React.useState([]); - const [draftContributions, setDraftContributions] = React.useState([]); - const [isDownloadDone, setIsDownloadDone] = React.useState(true); - const [isLoading, setIsLoading] = useState(true); - //const [error, setError] = React.useState(null); - const [isActionMenuOpen, setIsActionMenuOpen] = React.useState<{ [key: number | string]: boolean }>({}); - const router = useRouter(); - - React.useEffect(() => { - const fetchAndSetPullRequests = async () => { - if (session?.accessToken) { - try { - const header = { - Authorization: `Bearer ${session.accessToken}`, - Accept: 'application/vnd.github.v3+json' - }; - const fetchedUsername = await getGitHubUsername(header); - const data = await fetchPullRequests(session.accessToken); - const filteredPRs = data.filter( - (pr: PullRequest) => pr.user.login === fetchedUsername && pr.labels.some((label) => label.name === 'skill' || label.name === 'knowledge') - ); - - // Sort by date (newest first) - const sortedPRs = filteredPRs.sort((a: PullRequest, b: PullRequest) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); - setPullRequests(sortedPRs); - } catch (error) { - console.error('Failed to fetch pull requests.' + error); - } - } - }; - - fetchAndSetPullRequests().then(() => { - setIsLoading(false); - }); - - const intervalId = setInterval(fetchAndSetPullRequests, 60000); - - return () => clearInterval(intervalId); - }, [session?.accessToken]); - - React.useEffect(() => { - // Fetch all the draft contributions and mark them submitted if present in the pull requests - const drafts = fetchDraftContributions().map((draft: DraftEditFormInfo) => ({ - ...draft, - isSubmitted: pullRequests.some((pr) => pr.head.ref === draft.branchName) - })); - - setDraftContributions(drafts); - }, [pullRequests]); - - const handleDeleteDraftContribution = async (branchName: string) => { - deleteDraftData(branchName); - const drafts = draftContributions.filter((item) => item.branchName != branchName); - setDraftContributions(drafts); - }; - - const handleEditDraftContribution = (branchName: string) => { - // Check if branchName contains string "knowledge" - if (branchName.includes('knowledge')) { - router.push(`/contribute/knowledge/github/${branchName}/isDraft`); - } else { - router.push(`/contribute/skill/github/${branchName}/isDraft`); - } - }; - - const handleEditClick = (pr: PullRequest) => { - const hasKnowledgeLabel = pr.labels.some((label) => label.name === 'knowledge'); - const hasSkillLabel = pr.labels.some((label) => label.name === 'skill'); - - if (draftContributions.find((draft) => draft.branchName == pr.head.ref)) { - // If user is editing the submitted contribution, use the latest data from draft, if available. - // Pass the pr number as well, it's required to pull the data from PR. - if (hasKnowledgeLabel) { - router.push(`/contribute/knowledge/github/${pr.head.ref}/isDraft`); - } else { - router.push(`/contribute/skill/github/${pr.head.ref}/isDraft`); - } - } else { - if (hasKnowledgeLabel) { - router.push(`/contribute/knowledge/github/${pr.number}`); - } else if (hasSkillLabel) { - router.push(`/contribute/skill/github/${pr.number}`); - } - } - }; - - const handleOnClose = () => { - setIsLoading(false); - }; - - if (!session) { - return
Loading...
; - } - - const onActionMenuToggle = (id: number | string, isOpen: boolean) => { - setIsActionMenuOpen((prevState) => ({ - ...prevState, - [id]: isOpen - })); - }; - - const onActionMenuSelect = (id: number | string) => { - setIsActionMenuOpen((prevState) => ({ - ...prevState, - [id]: false - })); - }; - - return ( - <> - - - My Submissions - - - View and manage your taxonomy contributions. - - Taxonomy contributions help tune the InstructLab model. Contributions can include skills that teach the model how to do something or - knowledge that teaches the model facts, data, or references.{' '} - - Learn more - - - } - > - - - - - -
- {isLoading && ( - handleOnClose()}> - -
- - Retrieving all your skills and knowledge submissions from taxonomy repository. -
-
-
- )} - {!isDownloadDone && ( - setIsDownloadDone(true)}> - -
- - Retrieving the taxonomy compressed file with the contributed data. -
-
-
- )} - {!isLoading && pullRequests.length === 0 && draftContributions.length === 0 ? ( - - -
- InstructLab is a powerful and accessible tool for advancing generative AI through community collaboration and open-source principles. - By contributing your own data, you can help train and refine the language model.
-
- To get started, contribute a skill or contribute knowledge. -
-
- - - - - - - - - - -
- ) : ( - - {draftContributions.map( - (draft, index) => - !pullRequests.find((pr) => pr.head.ref == draft.branchName) && ( - - - onActionMenuSelect(draft.branchName)} - toggle={(toggleRef: React.Ref) => ( - onActionMenuToggle(draft.branchName, !isActionMenuOpen[draft.branchName])} - variant="plain" - aria-label="contribution action menu" - icon={ - - - Branch name: {draft.branchName} - State: Draft - Last updated: {draft.lastUpdated} - - {draft.isKnowledgeDraft ? ( - - ) : ( - - )} - - - - - - ) - )} - - {pullRequests.map((pr) => ( - - - onActionMenuSelect(pr.number)} - toggle={(toggleRef: React.Ref) => ( - onActionMenuToggle(pr.number, !isActionMenuOpen[pr.number])} - variant="plain" - aria-label="contribution action menu" - icon={ - - - Branch name: {pr.head.ref} - State: {pr.state} - Last updated: {new Date(pr.updated_at).toUTCString()} - - {pr.labels.map((label) => ( - - ))} - - - - - - ))} - - )} - - - ); -}; - -export { DashboardGithub }; diff --git a/src/components/Dashboard/Native/DashboardPage.tsx b/src/components/Dashboard/Native/DashboardPage.tsx new file mode 100644 index 00000000..6d910a5f --- /dev/null +++ b/src/components/Dashboard/Native/DashboardPage.tsx @@ -0,0 +1,185 @@ +// src/components/Dashboard/Native/DashboardPage.tsx +import * as React from 'react'; +import { AlertProps } from '@patternfly/react-core'; +import { v4 as uuidv4 } from 'uuid'; +import { ContributionInfo, DraftEditFormInfo } from '@/types'; +import { fetchDraftContributions } from '@/components/Contribute/Utils/autoSaveUtils'; +import { useFeatureFlags } from '@/context/FeatureFlagsContext'; +import Dashboard, { AlertItem } from '@/components/Dashboard/Dashboard'; + +const fetchBranchTaxonomy = async (branchName: string) => { + let taxonomy = ''; + try { + const response = await fetch('/api/native/git/branches', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ branchName, action: 'diff' }) + }); + + const result = await response.json(); + if (response.ok) { + if (result?.changes.length > 0) { + result.changes.forEach((change: { status: string; content?: string; file: string }) => { + if (change.status !== 'deleted' && change.content) { + if (change.file.includes('qna.yaml')) { + // Set the file path from the current YAML file (remove the root folder name from the path) + const currentFilePath = change.file.split('/').slice(1, -1).join('/'); + taxonomy = currentFilePath + '/'; + } + } + }); + } + } + } catch (error) { + console.error('Error fetching branch changes:', error); + } + + return taxonomy; +}; + +const cloneNativeTaxonomyRepo = async (): Promise => { + try { + const response = await fetch('/api/native/clone-repo', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const result = await response.json(); + if (response.ok) { + return true; + } + console.error(result.message); + return false; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Error cloning repo:', errorMessage); + return false; + } +}; + +const DashboardNative: React.FunctionComponent = () => { + const { + featureFlags: { skillFeaturesEnabled } + } = useFeatureFlags(); + const [branches, setBranches] = React.useState< + { name: string; creationDate: number; message: string; author: string; state: string; taxonomy: string }[] + >([]); + const [draftContributions, setDraftContributions] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + const [alerts, setAlerts] = React.useState([]); + + const addAlert = React.useCallback((title: string, variant: AlertProps['variant']) => { + const alertKey = uuidv4(); + const newAlert: AlertItem = { title, variant, key: alertKey }; + setAlerts((prevAlerts) => [...prevAlerts, newAlert]); + }, []); + + const removeAlert = (alertToRemove: AlertItem) => { + setAlerts((prevAlerts) => prevAlerts.filter((alert) => alert.key !== alertToRemove.key)); + }; + + const fetchBranches = React.useCallback(async () => { + try { + const response = await fetch('/api/native/git/branches'); + const result = await response.json(); + if (response.ok) { + // Filter out 'main' branch + const filteredBranches = result.branches.filter( + (branch: { name: string }) => branch.name !== 'main' && (skillFeaturesEnabled || branch.name.includes('knowledge-contribution')) + ); + for (const branch of filteredBranches) { + branch.taxonomy = await fetchBranchTaxonomy(branch.name); + } + setBranches(filteredBranches); + } else { + console.error('Failed to fetch branches:', result.error); + addAlert(result.error || 'Failed to fetch branches.', 'danger'); + } + } catch (error) { + console.error('Error fetching branches:', error); + addAlert('Error fetching branches.', 'danger'); + } + }, [addAlert, skillFeaturesEnabled]); + + // Fetch branches from the API route + React.useEffect(() => { + let refreshIntervalId: NodeJS.Timeout; + + cloneNativeTaxonomyRepo().then((success) => { + if (success) { + fetchBranches().then(() => { + setIsLoading(false); + }); + refreshIntervalId = setInterval(fetchBranches, 60000); + } else { + addAlert('Failed to fetch branches.', 'danger'); + setIsLoading(false); + } + }); + + return () => clearInterval(refreshIntervalId); + }, [addAlert, fetchBranches]); + + React.useEffect(() => { + // Fetch all the draft contributions and mark them submitted if present in the branches + const drafts = fetchDraftContributions() + .map((draft: DraftEditFormInfo) => ({ + ...draft, + isSubmitted: branches.some((branch) => branch.name === draft.branchName) + })) + .filter((draft) => skillFeaturesEnabled || draft.isKnowledgeDraft); + + setDraftContributions(drafts); + }, [branches, skillFeaturesEnabled]); + + const contributions: ContributionInfo[] = React.useMemo( + () => [ + ...draftContributions + .filter((draft) => !branches.find((branch) => branch.name === draft.branchName)) + .map((draft) => ({ + branchName: draft.branchName, + title: draft.title || `Draft ${draft.isKnowledgeDraft ? 'knowledge' : 'skill'} contribution`, + author: draft.author, + lastUpdated: new Date(draft.lastUpdated), + isDraft: true, + isKnowledge: draft.isKnowledgeDraft, + isSubmitted: draft.isSubmitted, + state: 'draft', + taxonomy: draft.taxonomy + })), + ...branches.map((branch) => ({ + branchName: branch.name, + title: branch.message, + author: branch.author, + lastUpdated: (() => { + const date = new Date(); + date.setTime(branch.creationDate); + return date; + })(), + isDraft: !!draftContributions.find((draft) => draft.branchName == branch.name), + isKnowledge: branch.name.includes('knowledge-contribution'), + isSubmitted: true, + state: branch.state, + taxonomy: branch.taxonomy + })) + ], + [branches, draftContributions] + ); + + const onUpdateContributions = () => { + fetchBranches(); + }; + + return ( + + ); +}; + +export { DashboardNative }; diff --git a/src/components/Dashboard/Native/dashboard.tsx b/src/components/Dashboard/Native/dashboard.tsx deleted file mode 100644 index 1e95b724..00000000 --- a/src/components/Dashboard/Native/dashboard.tsx +++ /dev/null @@ -1,712 +0,0 @@ -// src/components/Dashboard/Native/dashboard.tsx -import * as React from 'react'; -import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { - AlertProps, - PageSection, - Title, - Content, - Popover, - Button, - AlertGroup, - Alert, - AlertVariant, - AlertActionCloseButton, - Spinner, - EmptyState, - EmptyStateBody, - EmptyStateFooter, - EmptyStateActions, - Card, - CardBody, - Flex, - FlexItem, - Modal, - ModalVariant, - ModalBody, - ModalFooter, - ModalHeader, - DropdownItem, - Dropdown, - MenuToggleElement, - MenuToggle, - DropdownList, - CardHeader, - CardTitle, - Gallery, - GalleryItem, - Label -} from '@patternfly/react-core'; -import { ExternalLinkAltIcon, OutlinedQuestionCircleIcon, GithubIcon, EllipsisVIcon, PficonTemplateIcon } from '@patternfly/react-icons'; -import { ExpandableSection } from '@patternfly/react-core/dist/esm/components/ExpandableSection/ExpandableSection'; -import { v4 as uuidv4 } from 'uuid'; -import { DraftEditFormInfo } from '@/types'; -import { deleteDraftData, fetchDraftContributions } from '@/components/Contribute/Utils/autoSaveUtils'; -import { handleTaxonomyDownload } from '@/utils/taxonomy'; -import { useEnvConfig } from '@/context/EnvConfigContext'; - -const InstructLabLogo: React.FC = () => ; - -interface ChangeData { - file: string; - status: string; - content?: string; - commitSha?: string; -} - -interface AlertItem { - title: string; - variant: AlertProps['variant']; - key: React.Key; -} - -const cloneNativeTaxonomyRepo = async (): Promise => { - try { - const response = await fetch('/api/native/clone-repo', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const result = await response.json(); - if (response.ok) { - console.log(result.message); - return true; - } else { - console.error(result.message); - return false; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - console.error('Error cloning repo:', errorMessage); - return false; - } -}; - -const DashboardNative: React.FunctionComponent = () => { - const { - envConfig: { taxonomyRootDir } - } = useEnvConfig(); - const [branches, setBranches] = React.useState<{ name: string; creationDate: number; message: string; author: string }[]>([]); - const [draftContributions, setDraftContributions] = React.useState([]); - const [isLoading, setIsLoading] = React.useState(true); - const [mergeStatus] = React.useState<{ branch: string; message: string; success: boolean } | null>(null); - const [diffData, setDiffData] = React.useState<{ branch: string; changes: ChangeData[] } | null>(null); - const [isActionMenuOpen, setIsActionMenuOpen] = React.useState<{ [key: string]: boolean }>({}); - const [isChangeModalOpen, setIsChangeModalOpen] = React.useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = React.useState(false); - const [isPublishModalOpen, setIsPublishModalOpen] = React.useState(false); - const [alerts, setAlerts] = React.useState([]); - const [selectedBranch, setSelectedBranch] = React.useState(null); - const [selectedDraftContribution, setSelectedDraftContribution] = React.useState(null); - const [isPublishing, setIsPublishing] = React.useState(false); - const [expandedFiles, setExpandedFiles] = React.useState>({}); - const [isDownloadDone, setIsDownloadDone] = React.useState(true); - - const router = useRouter(); - - const addAlert = (title: string, variant: AlertProps['variant']) => { - const alertKey = uuidv4(); - const newAlert: AlertItem = { title, variant, key: alertKey }; - setAlerts((prevAlerts) => [...prevAlerts, newAlert]); - }; - - const removeAlert = (key: React.Key) => { - setAlerts((prevAlerts) => prevAlerts.filter((alert) => alert.key !== key)); - }; - - const addSuccessAlert = (message: string) => { - addAlert(message, 'success'); - }; - - const addDangerAlert = React.useCallback((message: string) => { - addAlert(message, 'danger'); - }, []); - - // Fetch branches from the API route - React.useEffect(() => { - let refreshIntervalId: NodeJS.Timeout; - - const fetchBranches = async () => { - const success = await cloneNativeTaxonomyRepo(); - if (success) { - try { - const response = await fetch('/api/native/git/branches'); - const result = await response.json(); - if (response.ok) { - // Filter out 'main' branch - const filteredBranches = result.branches.filter((branch: { name: string }) => branch.name !== 'main'); - setBranches(filteredBranches); - } else { - console.error('Failed to fetch branches:', result.error); - addDangerAlert(result.error || 'Failed to fetch branches.'); - } - } catch (error) { - console.error('Error fetching branches:', error); - addDangerAlert('Error fetching branches.'); - } - } - }; - - cloneNativeTaxonomyRepo().then((success) => { - if (success) { - fetchBranches().then(() => { - setIsLoading(false); - }); - refreshIntervalId = setInterval(fetchBranches, 60000); - } else { - addDangerAlert('Failed to fetch branches.'); - setIsLoading(false); - } - }); - - return () => clearInterval(refreshIntervalId); - }, [addDangerAlert]); - - React.useEffect(() => { - // Fetch all the draft contributions and mark them submitted if present in the branches - const drafts = fetchDraftContributions().map((draft: DraftEditFormInfo) => ({ - ...draft, - isSubmitted: branches.some((branch) => branch.name === draft.branchName) - })); - - setDraftContributions(drafts); - }, [branches]); - - const formatDateTime = (timestamp: number) => { - const date = new Date(timestamp); - return date.toLocaleString(); - }; - - const handleShowChanges = async (branchName: string) => { - try { - const response = await fetch('/api/native/git/branches', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ branchName, action: 'diff' }) - }); - - const result = await response.json(); - if (response.ok) { - setDiffData({ branch: branchName, changes: result.changes }); - setIsChangeModalOpen(true); - } else { - console.error('Failed to get branch changes:', result.error); - } - } catch (error) { - console.error('Error fetching branch changes:', error); - } - }; - - const handleDeleteContribution = async (branchName: string) => { - setSelectedBranch(branchName); - setIsDeleteModalOpen(true); - }; - - const handleDeleteDraftContribution = async (branchName: string) => { - setSelectedDraftContribution(branchName); - setIsDeleteModalOpen(true); - }; - - const handleDeleteContributionConfirm = async () => { - if (selectedBranch) { - // If draft exist in the local storage, delete it. - if (draftContributions.find((draft) => draft.branchName == selectedBranch)) { - deleteDraftData(selectedBranch); - } - await deleteContribution(selectedBranch); - setIsDeleteModalOpen(false); - } - if (selectedDraftContribution) { - //Remove draft from local storage and update the draftContributions list. - deleteDraftData(selectedDraftContribution); - - const drafts = draftContributions.filter((item) => item.branchName != selectedDraftContribution); - setDraftContributions(drafts); - setIsDeleteModalOpen(false); - } - }; - - const handleDeleteContributionCancel = () => { - setSelectedBranch(null); - setIsDeleteModalOpen(false); - }; - - const deleteContribution = async (branchName: string) => { - try { - const response = await fetch('/api/native/git/branches', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ branchName, action: 'delete' }) - }); - - const result = await response.json(); - if (response.ok) { - // Remove the branch from the list - setBranches((prevBranches) => prevBranches.filter((branch) => branch.name !== branchName)); - addSuccessAlert(result.message); - } else { - console.error(result.error); - addDangerAlert(result.error); - } - } catch (error) { - if (error instanceof Error) { - const errorMessage = 'Error deleting branch ' + branchName + ':' + error.message; - console.error(errorMessage); - addDangerAlert(errorMessage); - } else { - console.error('Unknown error deleting the contribution ${branchName}'); - addDangerAlert('Unknown error deleting the contribution ${branchName}'); - } - } - }; - - const handleEditDraftContribution = (branchName: string) => { - setSelectedDraftContribution(branchName); - // Check if branchName contains string "knowledge" - if (branchName.includes('knowledge')) { - router.push(`/contribute/knowledge/native/${branchName}/isDraft`); - } else { - router.push(`/contribute/skill/native/${branchName}/isDraft`); - } - }; - - const handleEditContribution = (branchName: string) => { - setSelectedBranch(branchName); - - if (draftContributions.find((draft) => draft.branchName == branchName)) { - // If user is editing the submitted contribution, use the latest data from draft. - if (branchName.includes('knowledge')) { - router.push(`/contribute/knowledge/native/${branchName}/isDraft`); - } else { - router.push(`/contribute/skill/native/${branchName}/isDraft`); - } - } else { - // Check if branchName contains string "knowledge" - if (branchName.includes('knowledge')) { - router.push(`/contribute/knowledge/native/${branchName}`); - } else { - router.push(`/contribute/skill/native/${branchName}`); - } - } - }; - - const handlePublishContribution = async (branchName: string) => { - setSelectedBranch(branchName); - setIsPublishModalOpen(true); - }; - - const handlePublishContributionConfirm = async () => { - setIsPublishing(true); - if (selectedBranch) { - try { - const response = await fetch('/api/native/git/branches', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ branchName: selectedBranch, action: 'publish' }) - }); - - const result = await response.json(); - if (response.ok) { - setIsPublishing(false); - addSuccessAlert(result.message || 'Successfully published contribution.'); - setSelectedBranch(null); - setIsPublishModalOpen(false); - } else { - console.error('Failed to publish the contribution:', result.error); - addDangerAlert(result.error || 'Failed to publish the contribution.'); - } - } catch (error) { - console.error('Error while publishing the contribution:', error); - addDangerAlert(`Error while publishing the contribution: ${error}`); - } - } else { - addDangerAlert('No branch selected to publish'); - } - setIsPublishing(false); - setSelectedBranch(null); - setIsPublishModalOpen(false); - }; - - const handlePublishContributionCancel = () => { - setSelectedBranch(null); - setIsPublishModalOpen(false); - }; - - const toggleFileContent = (filename: string) => { - setExpandedFiles((prev) => ({ - ...prev, - [filename]: !prev[filename] - })); - }; - - const onActionMenuToggle = (id: string, isOpen: boolean) => { - setIsActionMenuOpen((prevState) => ({ - ...prevState, - [id]: isOpen - })); - }; - - const onActionMenuSelect = (id: string) => { - setIsActionMenuOpen((prevState) => ({ - ...prevState, - [id]: false - })); - }; - - return ( - <> - - - My Submissions - - - View and manage your taxonomy contributions. - - Taxonomy contributions help tune the InstructLab model. Contributions can include skills that teach the model how to do something or - knowledge that teaches the model facts, data, or references.{' '} - - Learn more - -
- } - > - - - -
- - - - {alerts.map(({ key, variant, title }) => ( - removeAlert(key!)} />} - key={key} - /> - ))} - - - {!isDownloadDone && ( - setIsDownloadDone(true)}> - -
- - Retrieving the taxonomy compressed file with the contributed data. -
-
-
- )} - - {isLoading ? ( - - ) : branches.length === 0 && draftContributions.length === 0 ? ( - - -
- InstructLab is a powerful and accessible tool for advancing generative AI through community collaboration and open-source principles. - By contributing your own data, you can help train and refine the language model.
-
- To get started, contribute a skill or contribute knowledge. -
-
- - - - - - - - - - -
- ) : ( - - {draftContributions.map( - (draft, index) => - // Only display the drafts that's not submitted yet. - !branches.find((branch) => branch.name == draft.branchName) && ( - - - onActionMenuSelect(draft.branchName)} - toggle={(toggleRef: React.Ref) => ( - onActionMenuToggle(draft.branchName, !isActionMenuOpen[draft.branchName])} - variant="plain" - aria-label="contribution action menu" - icon={ - - - Branch name: {draft.branchName} - State: Draft - Last updated: {draft.lastUpdated} - - {draft.isKnowledgeDraft ? ( - - ) : ( - - )} - - - - - - ) - )} - - {branches.map((branch) => ( - - - onActionMenuSelect(branch.name)} - toggle={(toggleRef: React.Ref) => ( - onActionMenuToggle(branch.name, !isActionMenuOpen[branch.name])} - variant="plain" - aria-label="contribution action menu" - icon={ - - - Branch name: {branch.name} - Status: {draftContributions.find((draft) => draft.branchName == branch.name) ? 'Draft' : 'Open'} - Last updated: {formatDateTime(branch.creationDate)} - - {branch.name.includes('knowledge-contribution') ? ( - - ) : ( - - )} - - - - - - ))} - - )} - - {mergeStatus && ( - -

{mergeStatus.message}

-
- )} - - setIsChangeModalOpen(false)} - aria-labelledby="changes-contribution-modal-title" - aria-describedby="changes-contribution-body-variant" - > - - {diffData?.changes.length ? ( -
    - {diffData.changes.map((change) => ( -
  • -
    - {change.file} - {change.status} - Commit SHA: {change.commitSha} -
    - {change.status !== 'deleted' && change.content && ( - toggleFileContent(change.file)} - isExpanded={expandedFiles[change.file] || false} - > -
    -                          {change.content}
    -                        
    -
    - )} -
  • - ))} -
- ) : ( -

No differences found.

- )} -
-
- - setIsDeleteModalOpen(false)} - aria-labelledby="delete-contribution-modal-title" - aria-describedby="delete-contribution-body-variant" - > - - -

Are you sure you want to delete this contribution?

-
- - - - -
- - setIsPublishModalOpen(false)} - aria-labelledby="publish-contribution-modal-title" - aria-describedby="publish-contribution-body-variant" - > - - -

Are you sure you want to publish contribution to remote taxonomy repository present at : {taxonomyRootDir}?

-
- - - - -
-
- - ); -}; - -export { DashboardNative }; diff --git a/src/components/Dashboard/const.ts b/src/components/Dashboard/const.ts new file mode 100644 index 00000000..85e4fbec --- /dev/null +++ b/src/components/Dashboard/const.ts @@ -0,0 +1,130 @@ +import { SortableData } from '@/components/Table/types'; +import { ContributionInfo } from '@/types'; + +export const SORT_BY_TITLE = 'title'; +export const SORT_BY_TYPE = 'type'; +export const SORT_BY_TAXONOMY = 'taxonomy'; +export const SORT_BY_STATUS = 'status'; +export const SORT_BY_LAST_UPDATE = 'last updated'; + +export const SortTitles = { + [SORT_BY_TITLE]: 'Title', + [SORT_BY_TYPE]: 'Type', + [SORT_BY_TAXONOMY]: 'Taxonomy', + [SORT_BY_STATUS]: 'Status', + [SORT_BY_LAST_UPDATE]: 'Last updated' +}; + +export const SortByIndex = [SORT_BY_TITLE, SORT_BY_TYPE, SORT_BY_TAXONOMY, SORT_BY_STATUS, SORT_BY_LAST_UPDATE]; + +export const SORT_ASCENDING = 'asc'; +export const SORT_DESCENDING = 'desc'; + +export const DefaultSort = SORT_BY_LAST_UPDATE; +export const DefaultSortDir = SORT_ASCENDING; + +const titleSorter = (a: ContributionInfo, b: ContributionInfo) => { + return (a.title || '').localeCompare(b.title || ''); +}; + +const typeSorter = (a: ContributionInfo, b: ContributionInfo) => { + if (a.isKnowledge !== b.isKnowledge) { + return a.isKnowledge ? -1 : 1; + } + return (a.title || '').localeCompare(b.title || ''); +}; + +const taxonomySorter = (a: ContributionInfo, b: ContributionInfo) => { + const compValue = a.taxonomy.localeCompare(b.taxonomy); + if (compValue !== 0) { + return compValue; + } + return (a.title || '').localeCompare(b.title || ''); +}; + +const statusSorter = (a: ContributionInfo, b: ContributionInfo) => { + if (a.isDraft !== b.isDraft) { + return a.isDraft ? -1 : 1; + } + return (a.title || '').localeCompare(b.title || ''); +}; + +const lastUpdatedSorter = (a: ContributionInfo, b: ContributionInfo) => { + return a.lastUpdated.getTime() - b.lastUpdated.getTime(); +}; + +export const ContributionSorter = (sortField: string, sortDir: string) => (a: ContributionInfo, b: ContributionInfo) => { + switch (sortField) { + case 'title': + return titleSorter(a, b) * (sortDir === SORT_ASCENDING ? 1 : -1); + case 'type': + return typeSorter(a, b) * (sortDir === SORT_ASCENDING ? 1 : -1); + case 'taxonomy': + return taxonomySorter(a, b) * (sortDir === SORT_ASCENDING ? 1 : -1); + case 'status': + return statusSorter(a, b) * (sortDir === SORT_ASCENDING ? 1 : -1); + case 'last updated': + return lastUpdatedSorter(a, b) * (sortDir === SORT_ASCENDING ? 1 : -1); + default: + return titleSorter(a, b) * (sortDir === SORT_ASCENDING ? 1 : -1); + } +}; + +export const ContributionColumns: SortableData[] = [ + { + field: 'title', + label: 'Title', + sortable: titleSorter, + width: 30 + }, + { + field: 'type', + label: 'Type', + sortable: typeSorter + }, + { + field: 'taxonomy', + label: 'Taxonomy', + sortable: taxonomySorter + }, + { + field: 'status', + label: 'Status', + sortable: statusSorter + }, + { + field: 'lastUpdated', + label: 'Last updated', + sortable: lastUpdatedSorter + }, + { + label: ' ', + field: 'kebab', + sortable: false + } +]; + +export const LastUpdatedDateFormatter = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + hourCycle: 'h12', + minute: '2-digit', + timeZoneName: 'short' +}); + +export const getFormattedLastUpdatedDate = (date: Date) => LastUpdatedDateFormatter.format(date); + +export const getTaxonomyDir = (taxonomy: string): string => { + const parts = taxonomy.split('/'); + if (parts.length === 1) { + return taxonomy; + } + if (parts[parts.length - 1]) { + return parts[parts.length - 1]; + } + + return parts[parts.length - 2]; +}; diff --git a/src/components/HelpDropdown/HelpDropdown.tsx b/src/components/HelpDropdown/HelpDropdown.tsx index 583d1133..dde196a8 100644 --- a/src/components/HelpDropdown/HelpDropdown.tsx +++ b/src/components/HelpDropdown/HelpDropdown.tsx @@ -1,7 +1,8 @@ import { Dropdown, MenuToggleElement, MenuToggle, DropdownList, DropdownItem, Flex, FlexItem, Icon } from '@patternfly/react-core'; -import { OutlinedQuestionCircleIcon, ExternalLinkAltIcon } from '@patternfly/react-icons'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; import React, { useCallback, useState } from 'react'; import AboutInstructLab from '../AboutModal/AboutModal'; +import XsExternalLinkAltIcon from '@/components/Common/XsExternalLinkAltIcon'; const HelpDropdown: React.FC = () => { const [isOpen, setIsOpen] = useState(false); @@ -44,10 +45,10 @@ const HelpDropdown: React.FC = () => { }} > - + Documentation - + diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx new file mode 100644 index 00000000..5203b46d --- /dev/null +++ b/src/components/Table/Table.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { TbodyProps } from '@patternfly/react-table'; +import TableBase from './TableBase'; +import useTableColumnSort from './useTableColumnSort'; + +type TableProps = Omit< + React.ComponentProps>, + 'itemCount' | 'onPerPageSelect' | 'onSetPage' | 'page' | 'perPage' +> & { + tbodyProps?: TbodyProps & { ref?: React.Ref }; + defaultSortDirection?: 'asc' | 'desc'; + setCurrentSortAndDirection: (sortByIndex: number, sortDir: 'asc' | 'desc') => void; +}; + +const Table = ({ + data, + columns, + subColumns, + enablePagination, + defaultSortColumn = 0, + defaultSortDirection = 'asc', + setCurrentSortAndDirection, + ...props +}: TableProps): React.ReactElement => { + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState(10); + const sort = useTableColumnSort(columns, subColumns || [], defaultSortColumn, defaultSortDirection); + const sortedData = sort.transformData(data); + + let viewedData: T[]; + if (enablePagination) { + viewedData = sortedData.slice(pageSize * (page - 1), pageSize * page); + } else { + viewedData = sortedData; + } + + // update page to 1 if data changes (common when filter is applied) + React.useEffect(() => { + if (viewedData.length === 0) { + setPage(1); + } + }, [viewedData.length]); + + React.useEffect(() => { + setCurrentSortAndDirection(sort.sortIndex ?? defaultSortColumn, sort.sortDirection ?? defaultSortDirection); + }, [defaultSortColumn, defaultSortDirection, setCurrentSortAndDirection, sort.sortDirection, sort.sortIndex]); + + return ( + setPage(newPage)} + onPerPageSelect={(e, newSize, newPage) => { + setPageSize(newSize); + setPage(newPage); + }} + getColumnSort={sort.getColumnSort} + {...props} + /> + ); +}; + +export default Table; diff --git a/src/components/Table/TableBase.tsx b/src/components/Table/TableBase.tsx new file mode 100644 index 00000000..b2face73 --- /dev/null +++ b/src/components/Table/TableBase.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; +import { Flex, FlexItem, Pagination, PaginationProps, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem, Tooltip } from '@patternfly/react-core'; +import { Table, Thead, Tr, Th, TableProps, Caption, Tbody, Td, TbodyProps } from '@patternfly/react-table'; +import { GetColumnSort, SortableData } from './types'; +import { CHECKBOX_FIELD_ID } from './const'; + +type Props = { + data: DataType[]; + columns: SortableData[]; + subColumns?: SortableData[]; + hasNestedHeader?: boolean; + defaultSortColumn?: number; + rowRenderer: (data: DataType, rowIndex: number) => React.ReactNode; + enablePagination?: boolean | 'compact'; + toolbarContent?: React.ReactElement; + onClearFilters?: () => void; + emptyTableView?: React.ReactNode; + caption?: string; + footerRow?: (pageNumber: number) => React.ReactElement | null; + selectAll?: { + onSelect: (value: boolean) => void; + selected: boolean; + disabled?: boolean; + tooltip?: string; + }; + getColumnSort?: GetColumnSort; + disableItemCount?: boolean; + tbodyProps?: TbodyProps & { ref?: React.Ref }; +} & Omit & + Pick< + PaginationProps, + 'itemCount' | 'onPerPageSelect' | 'onSetPage' | 'page' | 'perPage' | 'perPageOptions' | 'toggleTemplate' | 'onNextClick' | 'onPreviousClick' + >; + +const defaultPerPageOptions = [ + { + title: '10', + value: 10 + }, + { + title: '20', + value: 20 + }, + { + title: '30', + value: 30 + } +]; + +const TableBase = ({ + data, + columns, + subColumns, + hasNestedHeader, + rowRenderer, + enablePagination, + toolbarContent, + onClearFilters, + emptyTableView, + caption, + selectAll, + footerRow, + tbodyProps, + perPage = 10, + page = 1, + perPageOptions = defaultPerPageOptions, + onSetPage, + onNextClick, + onPreviousClick, + onPerPageSelect, + getColumnSort, + itemCount = 0, + toggleTemplate, + ...props +}: Props): React.ReactElement => { + const selectAllRef = React.useRef(null); + const showPagination = enablePagination; + + const pagination = (variant: 'top' | 'bottom') => ( + + ); + + // Use a reference to store the heights of table rows once loaded + const tableRef = React.useRef(null); + const rowHeightsRef = React.useRef(); + + React.useLayoutEffect(() => { + const heights: number[] = []; + const rows = tableRef.current?.querySelectorAll(':scope > tbody > tr'); + rows?.forEach((r) => heights.push(r.offsetHeight)); + rowHeightsRef.current = heights; + }, []); + + const renderColumnHeader = (col: SortableData, i: number, isSubheader?: boolean) => { + if (col.field === CHECKBOX_FIELD_ID && selectAll) { + return ( + + +
+ ) : ( + // Table headers cannot be empty for a11y, table cells can -- https://dequeuniversity.com/rules/axe/4.0/empty-table-header +
selectAll.onSelect(value), + isDisabled: selectAll.disabled + }} + // TODO: Log PF bug -- when there are no rows this gets truncated + style={{ minWidth: '45px' }} + isSubheader={isSubheader} + aria-label="Select all" + /> + + ); + } + + return col.label ? ( + + {col.label} + + ); + }; + + const renderRows = () => data.map((row, rowIndex) => rowRenderer(row, rowIndex)); + + const table = ( + + {caption && } + + {/* Note from PF: following custom style can be removed when we can resolve misalignment issue natively */} + {columns.map((col, i) => renderColumnHeader(col, i))} + {subColumns?.length ? {subColumns.map((col, i) => renderColumnHeader(col, columns.length + i, true))} : null} + + {renderRows()} + {footerRow && footerRow(page)} +
{caption}
+ ); + + return ( + + {(toolbarContent || showPagination) && ( + + } + clearAllFilters={onClearFilters} + > + + {toolbarContent} + {showPagination && ( + + {pagination('top')} + + )} + + + + )} + 0 || !emptyTableView ? 'flex_1' : 'flexDefault' }} style={{ overflowY: 'auto' }}> + {table} + + {emptyTableView && data.length === 0 ? ( + +
{emptyTableView}
+
+ ) : null} + {showPagination ? {pagination('bottom')} : null} +
+ ); +}; + +export default TableBase; diff --git a/src/components/Table/TableRowTitleDescription.tsx b/src/components/Table/TableRowTitleDescription.tsx new file mode 100644 index 00000000..abc6ce36 --- /dev/null +++ b/src/components/Table/TableRowTitleDescription.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Truncate } from '@patternfly/react-core'; +import TruncatedText from '@/components/Common/TruncatedText'; + +type TableRowTitleDescriptionProps = { + title: React.ReactNode; + description?: string; + truncateDescriptionLines?: number; +}; + +const TableRowTitleDescription: React.FC = ({ title, description, truncateDescriptionLines }) => { + const descriptionNode = description ? ( + + {truncateDescriptionLines !== undefined ? ( + + ) : ( + + )} + + ) : null; + + return ( +
+
{title}
+ {descriptionNode} +
+ ); +}; + +export default TableRowTitleDescription; diff --git a/src/components/Table/const.ts b/src/components/Table/const.ts new file mode 100644 index 00000000..2c7c58f6 --- /dev/null +++ b/src/components/Table/const.ts @@ -0,0 +1,23 @@ +import { SortableData } from './types'; + +export const CHECKBOX_FIELD_ID = 'checkbox'; +export const KEBAB_FIELD_ID = 'kebab'; +export const EXPAND_FIELD_ID = 'expand'; + +export const checkboxTableColumn = (): SortableData => ({ + label: '', + field: CHECKBOX_FIELD_ID, + sortable: false +}); + +export const kebabTableColumn = (): SortableData => ({ + label: '', + field: KEBAB_FIELD_ID, + sortable: false +}); + +export const expandTableColumn = (): SortableData => ({ + label: '', + field: EXPAND_FIELD_ID, + sortable: false +}); diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts new file mode 100644 index 00000000..72120105 --- /dev/null +++ b/src/components/Table/types.ts @@ -0,0 +1,22 @@ +import { ThProps } from '@patternfly/react-table'; + +export type GetColumnSort = (columnIndex: number) => ThProps['sort']; + +export type SortableData = Pick< + ThProps, + 'hasRightBorder' | 'isStickyColumn' | 'stickyMinWidth' | 'stickyLeftOffset' | 'modifier' | 'width' | 'info' | 'visibility' | 'className' +> & { + label: string; + field: string; + colSpan?: number; + rowSpan?: number; + /** + * Set to false to disable sort. + * Set to true to handle string and number fields automatically (everything else is equal). + * Pass a function that will get the two results and what field needs to be matched. + * Assume ASC -- the result will be inverted internally if needed. + */ + sortable: boolean | ((a: T, b: T, keyField: string) => number); + // The below can be removed when PatternFly adds a replacement utility class for pf-v6-u-background-color-200 in v6 + style?: React.CSSProperties; +}; diff --git a/src/components/Table/useTableColumnSort.ts b/src/components/Table/useTableColumnSort.ts new file mode 100644 index 00000000..82520853 --- /dev/null +++ b/src/components/Table/useTableColumnSort.ts @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { GetColumnSort, SortableData } from './types'; + +type TableColumnSortProps = { + columns: SortableData[]; + subColumns?: SortableData[]; + sortDirection?: 'asc' | 'desc'; + setSortDirection: (dir: 'asc' | 'desc') => void; +}; + +type TableColumnSortByFieldProps = TableColumnSortProps & { + sortField?: string; + setSortField: (field: string) => void; +}; + +type TableColumnSortByIndexProps = TableColumnSortProps & { + sortIndex?: number; + setSortIndex: (index: number) => void; +}; + +export const getTableColumnSort = ({ + columns, + subColumns, + sortField, + setSortField, + ...sortProps +}: TableColumnSortByFieldProps): GetColumnSort => + getTableColumnSortByIndex({ + columns, + subColumns, + sortIndex: columns.findIndex((c) => c.field === sortField), + setSortIndex: (index: number) => setSortField(String(columns[index].field)), + ...sortProps + }); + +const getTableColumnSortByIndex = + ({ columns, subColumns, sortIndex, sortDirection, setSortIndex, setSortDirection }: TableColumnSortByIndexProps): GetColumnSort => + (columnIndex: number) => + (columnIndex < columns.length ? columns[columnIndex] : subColumns?.[columnIndex - columns.length])?.sortable + ? { + sortBy: { + index: sortIndex, + direction: sortDirection, + defaultDirection: 'asc' + }, + onSort: (_event, index, direction) => { + setSortIndex(index); + setSortDirection(direction); + }, + columnIndex + } + : undefined; +/** + * Using PF Composable Tables, this utility will help with handling sort logic. + * + * Use `transformData` on your data before you render rows. + * Use `getColumnSort` on your Th.sort as you render it (using the index of your column) + * + * @see https://www.patternfly.org/v4/components/table + */ +const useTableColumnSort = ( + columns: SortableData[], + subColumns: SortableData[], + defaultSortColIndex?: number, + defaultSortDirection?: 'desc' | 'asc' +): { + transformData: (data: T[]) => T[]; + getColumnSort: GetColumnSort; + sortIndex: number | undefined; + sortDirection: 'desc' | 'asc' | undefined; +} => { + const [activeSortIndex, setActiveSortIndex] = React.useState(defaultSortColIndex); + const [activeSortDirection, setActiveSortDirection] = React.useState<'desc' | 'asc' | undefined>(defaultSortDirection); + + return { + transformData: (data: T[]): T[] => { + if (activeSortIndex === undefined) { + return data; + } + + return data.toSorted((a, b) => { + const columnField = activeSortIndex < columns.length ? columns[activeSortIndex] : subColumns[activeSortIndex - columns.length]; + + const compute = () => { + if (typeof columnField.sortable === 'function') { + return columnField.sortable(a, b, columnField.field); + } + + if (!columnField.field) { + // If you lack the field, no auto sorting can be done + return 0; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const dataValueA = a[columnField.field as keyof T]; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const dataValueB = b[columnField.field as keyof T]; + if (typeof dataValueA === 'string' && typeof dataValueB === 'string') { + return dataValueA.localeCompare(dataValueB); + } + if (typeof dataValueA === 'number' && typeof dataValueB === 'number') { + return dataValueA - dataValueB; + } + return 0; + }; + + return compute() * (activeSortDirection === 'desc' ? -1 : 1); + }); + }, + getColumnSort: getTableColumnSortByIndex({ + columns, + subColumns, + sortDirection: activeSortDirection, + setSortDirection: setActiveSortDirection, + sortIndex: activeSortIndex, + setSortIndex: setActiveSortIndex + }), + sortIndex: activeSortIndex, + sortDirection: activeSortDirection + }; +}; + +export default useTableColumnSort; diff --git a/src/types/index.ts b/src/types/index.ts index a5c9b940..8cb8d702 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,6 +15,19 @@ export interface DraftEditFormInfo { isKnowledgeDraft: boolean; isSubmitted: boolean; oldFilesPath: string; + taxonomy: string; +} + +export interface ContributionInfo { + branchName: string; + title: string; + author?: string; + lastUpdated: Date; + isDraft: boolean; + isKnowledge: boolean; + isSubmitted: boolean; + state: string; + taxonomy: string; } export interface Endpoint { @@ -56,6 +69,7 @@ export interface PullRequest { head: { ref: string; }; + taxonomy: string; } export interface SkillYamlData { diff --git a/src/utils/github.ts b/src/utils/github.ts index 5a66b499..c3af49fb 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -1,8 +1,7 @@ // src/utils/github.ts import axios from 'axios'; -import { PullRequestUpdateData } from '@/types'; +import { EnvConfigType, PullRequestUpdateData } from '@/types'; import { BASE_BRANCH, FORK_CLONE_CHECK_RETRY_COUNT, FORK_CLONE_CHECK_RETRY_TIMEOUT, GITHUB_API_URL } from '@/types/const'; -import { fetchEnvConfig } from '@/utils/envConfigService'; type GithubUserInfo = { login: string; @@ -10,9 +9,9 @@ type GithubUserInfo = { email: string; }; -export async function fetchPullRequests(token: string) { +export async function fetchPullRequests(token: string, envConfig: EnvConfigType) { try { - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const response = await axios.get(`https://api.github.com/repos/${upstreamRepoOwner}/${upstreamRepoName}/pulls?state=all`, { headers: { @@ -31,9 +30,9 @@ export async function fetchPullRequests(token: string) { } } -export const fetchPullRequest = async (token: string, prNumber: number) => { +export const fetchPullRequest = async (token: string, envConfig: EnvConfigType, prNumber: number) => { try { - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const response = await axios.get(`https://api.github.com/repos/${upstreamRepoOwner}/${upstreamRepoName}/pulls/${prNumber}`, { headers: { Authorization: `Bearer ${token}`, @@ -55,9 +54,9 @@ export const fetchPullRequest = async (token: string, prNumber: number) => { } }; -export const fetchPullRequestFiles = async (token: string, prNumber: number) => { +export const fetchPullRequestFiles = async (token: string, envConfig: EnvConfigType, prNumber: number) => { try { - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const response = await axios.get(`https://api.github.com/repos/${upstreamRepoOwner}/${upstreamRepoName}/pulls/${prNumber}/files`, { headers: { Authorization: `Bearer ${token}`, @@ -75,9 +74,9 @@ export const fetchPullRequestFiles = async (token: string, prNumber: number) => } }; -export const fetchFileContent = async (token: string, filePath: string, ref: string) => { +export const fetchFileContent = async (token: string, envConfig: EnvConfigType, filePath: string, ref: string) => { try { - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const response = await axios.get(`https://api.github.com/repos/${upstreamRepoOwner}/${upstreamRepoName}/contents/${filePath}?ref=${ref}`, { headers: { Authorization: `Bearer ${token}`, @@ -95,10 +94,10 @@ export const fetchFileContent = async (token: string, filePath: string, ref: str } }; -export const updatePullRequest = async (token: string, prNumber: number, data: PullRequestUpdateData) => { +export const updatePullRequest = async (token: string, envConfig: EnvConfigType, prNumber: number, data: PullRequestUpdateData) => { try { console.log(`Updating PR Number: ${prNumber} with data:`, data); - const { upstreamRepoName, upstreamRepoOwner } = await fetchEnvConfig(); + const { upstreamRepoName, upstreamRepoOwner } = envConfig; const response = await axios.patch(`https://api.github.com/repos/${upstreamRepoOwner}/${upstreamRepoName}/pulls/${prNumber}`, data, { headers: { Authorization: `Bearer ${token}`,