From e8dd62aa6038c754a1e5e47e5e8924adaea0e6bc Mon Sep 17 00:00:00 2001 From: Ilya Topilskii Date: Mon, 27 Jan 2025 15:47:07 +0100 Subject: [PATCH 01/16] fix: fixes after design-review (#830) * fix: fixes after design-review * fix: fixes after code review --- packages/ui/src/components/badge.tsx | 2 +- .../ui/src/views/layouts/SandboxLayout.tsx | 2 +- .../create-pipeline-dialog.tsx | 62 +++++++++++-------- .../execution-list/execution-list-page.tsx | 59 ++++++++---------- .../execution-list/execution-list.tsx | 47 +++++++------- .../pipeline-list/pipeline-list-page.tsx | 45 ++++++-------- .../pipelines/pipeline-list/pipeline-list.tsx | 39 +++++------- .../pull-request-compare-diff-list.tsx | 2 +- .../pull-request-item-description.tsx | 4 +- .../pull-request/pull-request-list-page.tsx | 4 +- .../components/commit-changes.tsx | 6 +- .../components/commit-diff.tsx | 2 +- 12 files changed, 130 insertions(+), 144 deletions(-) diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index 1e6a4bd974..9b4f18d806 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -33,7 +33,7 @@ const badgeVariants = cva( xl: 'h-[18px] px-2 text-12', lg: 'px-3 py-1 text-xs font-normal', md: 'h-6 px-2.5', - sm: 'h-5 px-1 text-12', + sm: 'h-5 px-1.5 text-12', xs: 'px-1.5 py-0 text-11 font-light' }, borderRadius: { diff --git a/packages/ui/src/views/layouts/SandboxLayout.tsx b/packages/ui/src/views/layouts/SandboxLayout.tsx index 908b76114f..9d87c89888 100644 --- a/packages/ui/src/views/layouts/SandboxLayout.tsx +++ b/packages/ui/src/views/layouts/SandboxLayout.tsx @@ -59,7 +59,7 @@ function Main({ children, fullWidth, className }: { children: ReactNode; fullWid } return ( -
+
{children}
) diff --git a/packages/ui/src/views/pipelines/create-pipeline-dialog/create-pipeline-dialog.tsx b/packages/ui/src/views/pipelines/create-pipeline-dialog/create-pipeline-dialog.tsx index 424ab9e40b..85f2284f29 100644 --- a/packages/ui/src/views/pipelines/create-pipeline-dialog/create-pipeline-dialog.tsx +++ b/packages/ui/src/views/pipelines/create-pipeline-dialog/create-pipeline-dialog.tsx @@ -2,13 +2,13 @@ import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { + Alert, Button, ControlGroup, Dialog, + Fieldset, FormWrapper, Input, - Message, - MessageTheme, Select, SelectContent, SelectItem @@ -89,12 +89,12 @@ export function CreatePipelineDialog(props: CreatePipelineDialogProps) { reset() }} > - + Create Pipeline - - + +
+
+ +
+
+ +
- - {errorMessage} - -
- - -
- - +
+ + {errorMessage && ( + + {errorMessage} + + )} + + + + + +
) diff --git a/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx b/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx index b0ef87d0f5..00915ba8db 100644 --- a/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx +++ b/packages/ui/src/views/pipelines/execution-list/execution-list-page.tsx @@ -47,40 +47,33 @@ const ExecutionListPage: FC = ({ ) return ( - + - <> - -
- - Executions - -
- - - - - - - {/* TODO: two buttons - xd review required */} -
- - -
-
-
- - +

Executions

+ + + + + + + {/* TODO: two buttons - xd review required */} +
+ + +
+
+
+ { return ( -
+
{status && } - {title} + {title}
) } -const Description = ({ sha, description, version }: { sha: string; description: IExecutionType; version: string }) => { +const Description = ({ + sha, + description, + version +}: { + sha?: string + description?: IExecutionType + version?: string +}) => { return ( -
- {description && ( -
- - {description || ''} - -
- )} - {sha && ( -
- - {sha?.slice(0, 7)} -
- )} +
+ {description && {description}} {version && (
{version}
)} + {sha && ( +
+ + {sha?.slice(0, 7)} +
+ )}
) } @@ -94,15 +96,12 @@ export const ExecutionList = ({ {executions.map((execution, idx) => ( - + } description={ - + } /> = ({ ) return ( - + - <> - -
- - Pipelines - -
- - - - - - - - - - +

Pipelines

+ + + + + + + + + { return ( -
+
- {title} + {title}
) } -const Description = ({ sha, description, version }: { sha: string; description: string; version: string }) => { +const Description = ({ sha, description, version }: { sha?: string; description?: string; version?: string }) => { return ( -
- {description && ( -
- - {description || ''} - -
- )} - {sha && ( -
- - {sha?.slice(0, 7)} -
- )} +
+ {description && {description}} {version && (
{version}
)} + {sha && ( +
+ + {sha?.slice(0, 7)} +
+ )}
) } @@ -94,15 +88,12 @@ export const PipelineList = ({ {pipelines.map((pipeline, idx) => ( - + } description={ - + } /> } label secondary right /> diff --git a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx index 2fd41856ed..fb40b1f8cf 100644 --- a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx +++ b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx @@ -82,7 +82,7 @@ const PullRequestAccordion: FC = ({ } /> - +
{(fileDeleted || isDiffTooLarge || fileUnchanged || header?.isBinary) && !showHiddenDiff ? ( diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-item-description.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-item-description.tsx index 8dff298bad..e4230133de 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-item-description.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-item-description.tsx @@ -32,7 +32,7 @@ export const PullRequestItemDescription: FC = ( {`#${number}`} opened {timestamp} by {author}

- +

{reviewRequired ? 'Review required' : 'Draft'}

@@ -45,7 +45,7 @@ export const PullRequestItemDescription: FC = (

)} - + {sourceBranch && ( <> diff --git a/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx b/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx index 90aaf47c87..736d3065a7 100644 --- a/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx +++ b/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx @@ -135,7 +135,7 @@ const PullRequestList: FC = ({ } return ( - + {showTopBar ? ( <> @@ -149,7 +149,7 @@ const PullRequestList: FC = ({ className="max-w-96" value={searchInput || ''} handleChange={handleInputChange} - placeholder={t('views:repos.search')} + placeholder={t('views:repos.search', 'Search')} /> diff --git a/packages/ui/src/views/repo/repo-commit-details/components/commit-changes.tsx b/packages/ui/src/views/repo/repo-commit-details/components/commit-changes.tsx index 170f4c2620..997bdbe874 100644 --- a/packages/ui/src/views/repo/repo-commit-details/components/commit-changes.tsx +++ b/packages/ui/src/views/repo/repo-commit-details/components/commit-changes.tsx @@ -39,7 +39,7 @@ const LineTitle: FC = ({ header, useTranslationStore }) => { return (
- {text} + {text} {!!numAdditions && ( @@ -87,10 +87,10 @@ const CommitsAccordion: FC<{ - + } /> - +
{(fileDeleted || isDiffTooLarge || fileUnchanged || header?.isBinary) && !showHiddenDiff ? ( diff --git a/packages/ui/src/views/repo/repo-commit-details/components/commit-diff.tsx b/packages/ui/src/views/repo/repo-commit-details/components/commit-diff.tsx index ea2f882364..59abe11f28 100644 --- a/packages/ui/src/views/repo/repo-commit-details/components/commit-diff.tsx +++ b/packages/ui/src/views/repo/repo-commit-details/components/commit-diff.tsx @@ -17,7 +17,7 @@ export const CommitDiff: React.FC = ({ useCommitDetailsSto {t('views:commits.commitDetailsDiffShowing', 'Showing')}{' '} {diffStats?.files_changed || 0} {t('views:commits.commitDetailsDiffChangedFiles', 'changed files')} - + {' '} {t('views:commits.commitDetailsDiffWith', 'with')} {diffStats?.additions || 0}{' '} {t('views:commits.commitDetailsDiffAdditionsAnd', 'additions and')} {diffStats?.deletions || 0}{' '} {t('views:commits.commitDetailsDiffDeletions', 'deletions')} From 3176d15ecf4ec5e624efb9a946ca52de4f082336 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Mon, 27 Jan 2025 10:11:36 -0700 Subject: [PATCH 02/16] feat: [pipe-24078]: update design on deleted files and large files (#834) Signed-off-by: Calvin Lee Co-authored-by: Calvin Lee --- .../compare/components/pull-request-compare-diff-list.tsx | 8 ++++---- .../details/components/changes/pull-request-changes.tsx | 8 ++++---- .../repo-commit-details/components/commit-changes.tsx | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx index fb40b1f8cf..4e1ca30c3f 100644 --- a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx +++ b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-diff-list.tsx @@ -85,11 +85,11 @@ const PullRequestAccordion: FC = ({
{(fileDeleted || isDiffTooLarge || fileUnchanged || header?.isBinary) && !showHiddenDiff ? ( - + )} {err && {err}} - +
diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx index 4d8984d9a7..80d0a78e96 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx @@ -47,8 +47,8 @@ export const PullRequestItemTitle: FC = ({ merged }) => { return ( -
-
+
+
= ({ name={getPrState(isDraft, merged, state).icon} /> -

{title}

+

{title}

{labels?.map((l, l_idx) => { const { border, text, bg } = colorMapping[l.color] ? colorMapping[l.color] diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx index ea562afa16..306a9d1d31 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx @@ -73,7 +73,7 @@ export const PullRequestList: FC = ({ {pullRequest.number && ( <> Details diff --git a/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx b/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx index df291cbf77..59409e681d 100644 --- a/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx +++ b/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import { Badge, MoreActionsTooltip, NoData, PaginationComponent, Spacer, StackedList, Text } from '@/components' import { TranslationStore, WebhookType } from '@/views' @@ -97,38 +97,40 @@ export function RepoWebhookList({ <> {webhooks.map((webhook, webhook_idx) => ( - - {webhook.description}} - title={} - className="gap-1.5" - /> - <StackedList.Field - title={ - <MoreActionsTooltip - actions={[ - { - title: t('views:webhookData.edit', 'Edit webhook'), - to: `${webhook.id}` - }, - { - isDanger: true, - title: t('views:webhookData.delete', 'Delete webhook'), - onClick: () => openDeleteWebhookDialog(webhook.id) - } - ]} - /> - } - right - label - secondary - /> - </StackedList.Item> + <Link key={webhook.id} to={`${webhook.id}`}> + <StackedList.Item + key={webhook.createdAt} + className="py-3 pr-1.5 cursor-pointer" + isLast={webhooks.length - 1 === webhook_idx} + > + <StackedList.Field + primary + description={<span className="leading-none">{webhook.description}</span>} + title={<Title title={webhook.name} isEnabled={webhook.enabled} />} + className="gap-1.5" + /> + <StackedList.Field + title={ + <MoreActionsTooltip + actions={[ + { + title: t('views:webhookData.edit', 'Edit webhook'), + to: `${webhook.id}` + }, + { + isDanger: true, + title: t('views:webhookData.delete', 'Delete webhook'), + onClick: () => openDeleteWebhookDialog(webhook.id) + } + ]} + /> + } + right + label + secondary + /> + </StackedList.Item> + </Link> ))} </StackedList.Root> <PaginationComponent totalPages={totalPages} currentPage={page} goToPage={setPage} t={t} /> From 68c6db28a5f0747b1765f4794810a6eadbaf6950 Mon Sep 17 00:00:00 2001 From: Sanskar <c_sanskar.sehgal@harness.io> Date: Mon, 27 Jan 2025 11:32:03 -0800 Subject: [PATCH 04/16] feat: Adds the repo empty view (#836) * feat: add repo empty view * feat: add preview * fix: minor design fixes * fix: spacing --- .../src/pages/view-preview/view-preview.tsx | 6 + .../views/repo-empty/repo-empty-view.tsx | 12 ++ packages/ui/src/views/repo/index.ts | 1 + .../repo/repo-summary/repo-empty-view.tsx | 105 ++++++++++++++++++ .../views/repo/repo-summary/repo-summary.tsx | 12 +- 5 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 apps/design-system/src/subjects/views/repo-empty/repo-empty-view.tsx create mode 100644 packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx diff --git a/apps/design-system/src/pages/view-preview/view-preview.tsx b/apps/design-system/src/pages/view-preview/view-preview.tsx index 83ac18567f..c48e54dd6e 100644 --- a/apps/design-system/src/pages/view-preview/view-preview.tsx +++ b/apps/design-system/src/pages/view-preview/view-preview.tsx @@ -18,6 +18,7 @@ import { RepoBranchesView } from '@subjects/views/repo-branches' import { RepoCommitsView } from '@subjects/views/repo-commits' import { CreateRepoView } from '@subjects/views/repo-create' import { RepoCreateRule } from '@subjects/views/repo-create-rule' +import { RepoEmpty } from '@subjects/views/repo-empty/repo-empty-view' import { RepoFilesEditView } from '@subjects/views/repo-files/repo-files-edit-view' import { RepoFilesJsonView } from '@subjects/views/repo-files/repo-files-json-view' import { RepoFilesList } from '@subjects/views/repo-files/repo-files-list' @@ -62,6 +63,11 @@ export const viewPreviews: Record<string, ReactNode> = { <RepoSummaryViewWrapper /> </RepoViewWrapper> ), + 'repo-empty': ( + <RepoViewWrapper> + <RepoEmpty /> + </RepoViewWrapper> + ), 'repo-list': ( <RootViewWrapper> <RepoListWrapper /> diff --git a/apps/design-system/src/subjects/views/repo-empty/repo-empty-view.tsx b/apps/design-system/src/subjects/views/repo-empty/repo-empty-view.tsx new file mode 100644 index 0000000000..ed7f3f8394 --- /dev/null +++ b/apps/design-system/src/subjects/views/repo-empty/repo-empty-view.tsx @@ -0,0 +1,12 @@ +import { RepoEmptyView } from '@harnessio/ui/views' + +export const RepoEmpty = () => { + return ( + <RepoEmptyView + httpUrl="https://github.com/mock-repo" + repoName="mock-repo" + projName="mock-project" + sshUrl="git@github.com:mock-repo.git" + /> + ) +} diff --git a/packages/ui/src/views/repo/index.ts b/packages/ui/src/views/repo/index.ts index bd8e8e5070..25c62b77c4 100644 --- a/packages/ui/src/views/repo/index.ts +++ b/packages/ui/src/views/repo/index.ts @@ -13,6 +13,7 @@ export * from '@views/repo/repo-create' // repo summary export * from '@views/repo/repo-summary/repo-summary' +export * from '@views/repo/repo-summary/repo-empty-view' // repo types export * from '@views/repo/repo.types' diff --git a/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx b/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx new file mode 100644 index 0000000000..fc7900b70f --- /dev/null +++ b/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx @@ -0,0 +1,105 @@ +import { + Button, + ButtonGroup, + ControlGroup, + CopyButton, + Fieldset, + FormSeparator, + Input, + MarkdownViewer, + NoData, + Spacer, + StyledLink, + Text +} from '@/components' +import { SandboxLayout } from '@/views' + +interface RepoEmptyViewProps { + repoName: string + projName: string + httpUrl: string + sshUrl: string +} + +export const RepoEmptyView: React.FC<RepoEmptyViewProps> = ({ repoName, projName, httpUrl, sshUrl }) => { + const getInitialCommitMarkdown = () => { + return ` +\`\`\`shell +cd ${repoName} +git branch -M main +echo '# Hello World' >> README.md +git add README.md +git commit -m 'Initial commit' +git push -u origin main +\`\`\` +` + } + + const getExistingRepoMarkdown = () => { + return ` +\`\`\`shell +git remote add origin http://localhost:3000/git/${projName}/${repoName}.git +git branch -M main +git push -u origin main +\`\`\` +` + } + return ( + <SandboxLayout.Main> + <SandboxLayout.Content className="max-w-[850px] mx-auto"> + <Text size={5} weight={'medium'}> + Repository + </Text> + <Spacer size={6} /> + <NoData + withBorder + iconName="no-repository" + title="This repository is empty" + description={['We recommend every repository include a', 'README, LICENSE, and .gitignore.']} + primaryButton={{ label: 'New file' }} + className="py-0 pb-0 min-h-[40vh]" + /> + <Spacer size={6} /> + + <Fieldset> + <Text size={4} weight="medium"> + Please Generate Git Cradentials if it’s your first time cloning the repository + </Text> + <Text size={3}>Git clone URL</Text> + <Input label="HTTP" value={httpUrl} readOnly rightElement={<CopyButton name={httpUrl} />} /> + <Input label="SSH" value={sshUrl} readOnly rightElement={<CopyButton name={sshUrl} />} /> + <ControlGroup> + <ButtonGroup> + <Button>Generate Clone Credentials</Button> + </ButtonGroup> + <p className="mt-2"> + You can also manage your git credential{' '} + <StyledLink to="/" relative="path"> + here + </StyledLink> + </p> + </ControlGroup> + + <FormSeparator /> + <Text size={4} weight="medium"> + Then push some content into it + </Text> + <MarkdownViewer source={getInitialCommitMarkdown()} /> + <Text size={4} weight="medium"> + Or you can push an existing repository + </Text> + <ControlGroup> + <MarkdownViewer source={getExistingRepoMarkdown()} /> + <p> + You might need to{' '} + <StyledLink to="/" relative="path"> + create an API token + </StyledLink>{' '} + In order to pull from or push into this repository. + </p> + </ControlGroup> + </Fieldset> + </SandboxLayout.Content> + </SandboxLayout.Main> + ) +} diff --git a/packages/ui/src/views/repo/repo-summary/repo-summary.tsx b/packages/ui/src/views/repo/repo-summary/repo-summary.tsx index c321e22a24..f4b093c5d0 100644 --- a/packages/ui/src/views/repo/repo-summary/repo-summary.tsx +++ b/packages/ui/src/views/repo/repo-summary/repo-summary.tsx @@ -28,6 +28,7 @@ import { formatDate } from '@utils/utils' import { CloneRepoDialog } from './components/clone-repo-dialog' import SummaryPanel from './components/summary-panel' +import { RepoEmptyView } from './repo-empty-view' interface RoutingProps { toRepoFiles: () => string @@ -119,12 +120,11 @@ export function RepoSummaryView({ if (!repoEntryPathToFileTypeMap.size) { return ( - <NoData - iconName="no-data-folder" - title="No files yet" - description={['There are no files in this repository yet.', 'Create new or import an existing file.']} - primaryButton={{ label: 'Create file' }} - secondaryButton={{ label: 'Import file' }} + <RepoEmptyView + sshUrl={repository?.git_ssh_url ?? 'could not fetch url'} + httpUrl={repository?.git_url ?? 'could not fetch url'} + repoName={repoId} + projName={spaceId} /> ) } From a0aa052d7ea4141d749633f8de79609fd07a0bbe Mon Sep 17 00:00:00 2001 From: Calvin Lee <calvin.lee@harness.io> Date: Mon, 27 Jan 2025 13:41:56 -0700 Subject: [PATCH 05/16] feat: [pipe-24076]: fix text on compare page (#842) * feat: [pipe-24076]: fix text on compare page Signed-off-by: Calvin Lee <cjlee@ualberta.ca> * feat: [pipe-24076]: fix api on branch get Signed-off-by: Calvin Lee <cjlee@ualberta.ca> * feat: [pipe-24076]: fix api on branch get Signed-off-by: Calvin Lee <cjlee@ualberta.ca> --------- Signed-off-by: Calvin Lee <cjlee@ualberta.ca> Co-authored-by: Calvin Lee <cjlee@ualberta.ca> --- .../src/pages-v2/pull-request/pull-request-compare.tsx | 9 ++++++++- apps/gitness/src/pages-v2/repo/repo-sidebar.tsx | 2 +- apps/gitness/src/pages-v2/repo/repo-summary.tsx | 2 +- packages/ui/locales/en/views.json | 4 ++-- .../pull-request/compare/pull-request-compare-page.tsx | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx b/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx index a359bd8293..04bd065fbc 100644 --- a/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx +++ b/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx @@ -244,7 +244,14 @@ export const CreatePullRequest = () => { } const { data: { body: branches } = {} } = useListBranchesQuery({ repo_ref: repoRef, - queryParams: { page: 0, limit: 10, query: sourceQuery || targetQuery || '', include_pullreqs: true } + queryParams: { + page: 0, + sort: 'date', + order: 'desc', + limit: 10, + query: sourceQuery || targetQuery || '', + include_pullreqs: true + } }) useEffect(() => { diff --git a/apps/gitness/src/pages-v2/repo/repo-sidebar.tsx b/apps/gitness/src/pages-v2/repo/repo-sidebar.tsx index da658c96be..0e50854358 100644 --- a/apps/gitness/src/pages-v2/repo/repo-sidebar.tsx +++ b/apps/gitness/src/pages-v2/repo/repo-sidebar.tsx @@ -57,7 +57,7 @@ export const RepoSidebar = () => { queryParams: { include_commit: false, sort: 'date', - order: orderSortDate.ASC, + order: orderSortDate.DESC, limit: 50, query: searchQuery } diff --git a/apps/gitness/src/pages-v2/repo/repo-summary.tsx b/apps/gitness/src/pages-v2/repo/repo-summary.tsx index 2b1f61f7c7..6c3d49c82a 100644 --- a/apps/gitness/src/pages-v2/repo/repo-summary.tsx +++ b/apps/gitness/src/pages-v2/repo/repo-summary.tsx @@ -73,7 +73,7 @@ export default function RepoSummaryPage() { const { data: { body: branches } = {} } = useListBranchesQuery({ repo_ref: repoRef, - queryParams: { include_commit: false, sort: 'date', order: orderSortDate.ASC, limit: 50, query: branchTagQuery } + queryParams: { include_commit: false, sort: 'date', order: orderSortDate.DESC, limit: 50, query: branchTagQuery } }) const { data: { body: branchDivergence = [] } = {}, mutate: calculateDivergence } = diff --git a/packages/ui/locales/en/views.json b/packages/ui/locales/en/views.json index 1d7d3babb3..c7e178b967 100644 --- a/packages/ui/locales/en/views.json +++ b/packages/ui/locales/en/views.json @@ -172,7 +172,7 @@ "noCommitsYet": "No commits yet", "noCommitsYetDescription": "Your commits will appear here once they're made. Start committing to see your changes reflected.", "compareChanges": "Compare and review just about anything", - "compareChangesDescription": "Branches, tags, commit ranges, and time ranges. In the same repository and across forks.", + "compareChangesDescription": "Branches and commit ranges can be reviewed within the same repository.", "clearFilters": "Clear filters", "noPullRequests": "There are no pull requests in this project yet.", "createNewPullRequest": "Create a new pull request.", @@ -231,7 +231,7 @@ "compareChangesCantMergeDesciption": "You can still create the pull request.", "compareChangesDiscussChanges": "Discuss and review the changes in this comparison with others.", "compareChangesDiscussChangesLink": "Learn about pull requests.", - "compareChangesChooseDifferent": "Choose different branches or forks above to discuss and review changes.", + "compareChangesChooseDifferent": "Choose different branches above to discuss and review changes.", "compareChangesViewPRLink": "View pull request", "compareChangesTabOverview": "Overview", "compareChangesTabCommits": "Commits", diff --git a/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx b/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx index 7881a1134f..8771fc165e 100644 --- a/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx +++ b/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx @@ -283,7 +283,7 @@ export const PullRequestComparePage: FC<PullRequestComparePageProps> = ({ ) : ( t( 'views:pullRequests.compareChangesChooseDifferent', - 'Choose different branches or forks above to discuss and review changes.' + 'Choose different branches above to discuss and review changes.' ) )} </p> From ad6eaff797c7af8e8c3c23676f8c798090767e66 Mon Sep 17 00:00:00 2001 From: Andrew Golovanov <spacewebdeveloper@gmail.com> Date: Tue, 28 Jan 2025 00:45:16 +0300 Subject: [PATCH 06/16] feat: improving the styles and functionality of the PR detail page (#820) Co-authored-by: Calvin Lee <calvin.lee@harness.io> --- .../pages/view-preview/repo-view-wrapper.tsx | 2 +- .../pull-request-conversation.tsx | 1 - .../pull-request-panelData.ts | 138 ++++++++++ apps/gitness/src/components-v2/app-shell.tsx | 2 +- .../pull-request-conversation.tsx | 2 - packages/ui/src/components/badge.tsx | 2 +- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/icon.tsx | 10 +- .../src/components/markdown-viewer/index.tsx | 10 +- packages/ui/src/components/node-group.tsx | 8 +- packages/ui/src/icons/attachment-image.svg | 12 + packages/ui/src/icons/attachment.svg | 10 +- packages/ui/src/icons/bold.svg | 5 +- packages/ui/src/icons/code.svg | 12 +- packages/ui/src/icons/collapse-comment.svg | 16 ++ packages/ui/src/icons/expand-comment.svg | 16 ++ packages/ui/src/icons/suggestion.svg | 13 + .../src/views/layouts/PullRequestLayout.tsx | 31 ++- .../pull-request-compare-tab-trigger-item.tsx | 24 +- .../components/pull-request-diff-viewer.tsx | 48 ++-- .../changes/pull-request-changes.tsx | 2 - .../conversation/pull-request-comment-box.tsx | 246 ++++++++++-------- .../pull-request-description-box.tsx | 21 +- .../conversation/pull-request-overview.tsx | 59 ++--- .../pull-request-system-comments.tsx | 22 +- .../pull-request-timeline-item.tsx | 208 +++++++++------ .../sections/pull-request-changes-section.tsx | 2 +- 27 files changed, 582 insertions(+), 342 deletions(-) create mode 100644 packages/ui/src/icons/attachment-image.svg create mode 100644 packages/ui/src/icons/collapse-comment.svg create mode 100644 packages/ui/src/icons/expand-comment.svg create mode 100644 packages/ui/src/icons/suggestion.svg diff --git a/apps/design-system/src/pages/view-preview/repo-view-wrapper.tsx b/apps/design-system/src/pages/view-preview/repo-view-wrapper.tsx index 7dc7963ce3..69b3a70d71 100644 --- a/apps/design-system/src/pages/view-preview/repo-view-wrapper.tsx +++ b/apps/design-system/src/pages/view-preview/repo-view-wrapper.tsx @@ -13,7 +13,7 @@ const RepoViewWrapper: FC<PropsWithChildren<React.HTMLAttributes<HTMLElement>>> path="*" element={ <> - <div className="layer-high sticky top-[55px] bg-background-1"> + <div className="layer-high bg-background-1 sticky top-[55px]"> <RepoSubheader useTranslationStore={useTranslationsStore} /> </div> {children} diff --git a/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-conversation.tsx b/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-conversation.tsx index 638fdb9899..f033d8633c 100644 --- a/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-conversation.tsx +++ b/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-conversation.tsx @@ -201,7 +201,6 @@ const PullRequestConversation: FC<PullRequestConversationProps> = ({ state }) => handleSaveComment={noop} currentUser={{ display_name: currentUserData?.display_name, uid: currentUserData?.uid }} onCopyClick={noop} - onCommentSaveAndStatusChange={noop} toggleConversationStatus={noop} onCommitSuggestion={noop} addSuggestionToBatch={noop} diff --git a/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-panelData.ts b/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-panelData.ts index 6edd0c9caf..b5901e8eef 100644 --- a/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-panelData.ts +++ b/apps/design-system/src/subjects/views/pull-request-conversation/pull-request-panelData.ts @@ -164,6 +164,144 @@ export const mockPullRequestActions = [ ] export const mockActivities = [ + { + id: 795, + created: 1737660580605, + updated: 1737660580605, + edited: 1737660580605, + parent_id: null, + repo_id: 22, + pullreq_id: 174, + order: 2, + sub_order: 0, + type: 'code-comment', + kind: 'change-comment', + text: 'We should add type checking for this function to improve type safety', + payload: { + title: '@@ -0,0 +1,4 @@', + lines: [ + "+import tailwindcssAnimate from 'tailwindcss-animate'", + "+import { PluginAPI } from 'tailwindcss/types/config'", + '+', + '+export default {' + ], + line_start_new: true, + line_end_new: true + }, + author: { + id: 3, + uid: 'admin', + display_name: 'Administrator', + email: 'admin@gitness.io', + type: 'user', + created: 1699863416002, + updated: 1699863416002 + }, + resolved: 1737660580605, + resolver: { + id: 3, + uid: 'admin', + display_name: 'Administrator', + email: 'admin@gitness.io', + type: 'user', + created: 1699863416002, + updated: 1699863416002 + }, + code_comment: { + outdated: false, + merge_base_sha: '12421f51a7cca90376cba8de0fe9b3289eb6e218', + source_sha: '34f4d7bbfeda153e4965395ac6a20e80dec63e57', + path: 'packages/canary/configs/tailwind.ts', + line_new: 2, + span_new: 1, + line_old: 0, + span_old: 0 + } + }, + { + id: 796, + created: 1737660580605, + updated: 1737660580605, + edited: 1737660580605, + parent_id: null, + repo_id: 22, + pullreq_id: 174, + order: 2, + sub_order: 0, + type: 'comment', + kind: 'comment', + text: 'Should we consider adding unit tests for the new animation components?', + payload: {}, + author: { + id: 3, + uid: 'admin', + display_name: 'Administrator', + email: 'admin@gitness.io', + type: 'user', + created: 1699863416002, + updated: 1699863416002 + }, + resolved: 1737660580607, + resolver: { + id: 3, + uid: 'admin', + display_name: 'Administrator', + email: 'admin@gitness.io', + type: 'user', + created: 1699863416002, + updated: 1699863416002 + } + }, + + { + id: 797, + created: 1737660580606, + updated: 1737660580606, + edited: 1737660580606, + parent_id: 796, + repo_id: 22, + pullreq_id: 174, + order: 2, + sub_order: 1, + type: 'comment', + kind: 'comment', + text: 'Yes, I agree. I will add tests for basic animations first.', + payload: {}, + author: { + id: 3, + uid: 'admin', + display_name: 'Administrator', + email: 'admin@gitness.io', + type: 'user', + created: 1699863416002, + updated: 1699863416002 + } + }, + + { + id: 798, + created: 1737660580607, + updated: 1737660580607, + edited: 1737660580607, + parent_id: 796, + repo_id: 22, + pullreq_id: 174, + order: 2, + sub_order: 2, + type: 'comment', + kind: 'comment', + text: 'Tests have been added in PR #123. We can close this discussion.', + payload: {}, + author: { + id: 3, + uid: 'admin', + display_name: 'Administrator', + email: 'admin@gitness.io', + type: 'user', + created: 1699863416002, + updated: 1699863416002 + } + }, { id: 792, created: 1737660563002, diff --git a/apps/gitness/src/components-v2/app-shell.tsx b/apps/gitness/src/components-v2/app-shell.tsx index 46ca226f0b..ab104f5bd7 100644 --- a/apps/gitness/src/components-v2/app-shell.tsx +++ b/apps/gitness/src/components-v2/app-shell.tsx @@ -216,7 +216,7 @@ export const AppShellMFE = () => { function BreadcrumbsAndOutlet({ className }: { className?: string }) { return ( <div className={cn('flex flex-col', className)}> - <div className="layer-high bg-background-1 sticky top-0"> + <div className="layer-high sticky top-0 bg-background-1"> <Breadcrumbs /> </div> <Outlet /> diff --git a/apps/gitness/src/pages-v2/pull-request/pull-request-conversation.tsx b/apps/gitness/src/pages-v2/pull-request/pull-request-conversation.tsx index 95f9d1ec00..0fbc2a662a 100644 --- a/apps/gitness/src/pages-v2/pull-request/pull-request-conversation.tsx +++ b/apps/gitness/src/pages-v2/pull-request/pull-request-conversation.tsx @@ -526,7 +526,6 @@ export default function PullRequestConversationPage() { onCommitSuggestionsBatch, suggestionsBatch, suggestionToCommit, - onCommentSaveAndStatusChange, toggleConversationStatus, handleUpload } = usePRCommonInteractions({ @@ -662,7 +661,6 @@ export default function PullRequestConversationPage() { handleSaveComment={handleSaveComment} currentUser={{ display_name: currentUserData?.display_name, uid: currentUserData?.uid }} onCopyClick={onCopyClick} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} toggleConversationStatus={toggleConversationStatus} onCommitSuggestion={onCommitSuggestion} addSuggestionToBatch={addSuggestionToBatch} diff --git a/packages/ui/src/components/badge.tsx b/packages/ui/src/components/badge.tsx index 9b4f18d806..0bc3775df0 100644 --- a/packages/ui/src/components/badge.tsx +++ b/packages/ui/src/components/badge.tsx @@ -34,7 +34,7 @@ const badgeVariants = cva( lg: 'px-3 py-1 text-xs font-normal', md: 'h-6 px-2.5', sm: 'h-5 px-1.5 text-12', - xs: 'px-1.5 py-0 text-11 font-light' + xs: 'h-[18px] px-1.5 text-11 font-light' }, borderRadius: { default: 'rounded-md', diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 43cb9f19da..20a9cb096d 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -6,7 +6,7 @@ import { cn } from '@utils/cn' import { cva, type VariantProps } from 'class-variance-authority' const buttonVariants = cva( - 'inline-flex items-center justify-center whitespace-nowrap rounded text-sm font-medium transition-colors disabled:pointer-events-none disabled:cursor-not-allowed', + 'inline-flex items-center justify-center whitespace-nowrap rounded text-14 font-medium transition-colors disabled:pointer-events-none disabled:cursor-not-allowed', { variants: { variant: { diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 97cf4ad85b..be10506d03 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -9,6 +9,7 @@ import AppleShortcut from '../icons/apple-shortcut.svg' import ArrowLong from '../icons/arrow-long.svg' import ArtifactsGradient from '../icons/artifacts-gradient.svg' import Artifacts from '../icons/artifacts-icon.svg' +import AttachmentImage from '../icons/attachment-image.svg' import Attachment from '../icons/attachment.svg' import BitrisePlugin from '../icons/bitrise-plugin.svg' import Bold from '../icons/bold.svg' @@ -45,6 +46,7 @@ import CloudCosts from '../icons/cloud-costs-icon.svg' import CodeBrackets from '../icons/code-brackets.svg' import Code from '../icons/code.svg' import Cog6 from '../icons/cog-6.svg' +import CollapseComment from '../icons/collapse-comment.svg' import CollapseDiff from '../icons/collapse-diff.svg' import Comments from '../icons/comments.svg' import Compare from '../icons/compare.svg' @@ -67,6 +69,7 @@ import Edit from '../icons/edit-icon.svg' import Environment from '../icons/environment-icon.svg' import ExecutionGradient from '../icons/execution-gradient.svg' import Execution from '../icons/execution-icon.svg' +import ExpandComment from '../icons/expand-comment.svg' import ExpandDiff from '../icons/expand-diff.svg' import Eye from '../icons/eye-icon.svg' import Fail from '../icons/fail.svg' @@ -159,6 +162,7 @@ import Stack from '../icons/stack-icon.svg' import Star from '../icons/star-icon.svg' import SubMenuEllipse from '../icons/sub-menu-ellipse.svg' import Success from '../icons/success.svg' +import Suggestion from '../icons/suggestion.svg' import SupplyChainGradient from '../icons/supply-chain-gradient.svg' import SupplyChain from '../icons/supply-chain-icon.svg' import Tag from '../icons/tag.svg' @@ -359,7 +363,11 @@ const IconNameMap = { 'collapse-diff': CollapseDiff, 'expand-diff': ExpandDiff, 'circle-plus': CirclePlus, - 'code-brackets': CodeBrackets + 'code-brackets': CodeBrackets, + 'attachment-image': AttachmentImage, + 'collapse-comment': CollapseComment, + 'expand-comment': ExpandComment, + suggestion: Suggestion } satisfies Record<string, React.FunctionComponent<React.SVGProps<SVGSVGElement>>> export interface IconProps { diff --git a/packages/ui/src/components/markdown-viewer/index.tsx b/packages/ui/src/components/markdown-viewer/index.tsx index 1ef96f5d9e..bf5a0be4f8 100644 --- a/packages/ui/src/components/markdown-viewer/index.tsx +++ b/packages/ui/src/components/markdown-viewer/index.tsx @@ -10,6 +10,8 @@ import rehypeVideo from 'rehype-video' import './style.css' +import { cn } from '@utils/cn' + import { CodeSuggestionBlock, SuggestionBlock } from './CodeSuggestionBlock' // TODO: add ai stuff at a later point for code suggestions @@ -36,6 +38,7 @@ interface MarkdownViewerProps { suggestionBlock?: SuggestionBlock suggestionCheckSum?: string isSuggestion?: boolean + markdownClassName?: string } export function MarkdownViewer({ @@ -44,7 +47,8 @@ export function MarkdownViewer({ withBorderWrapper = false, suggestionBlock, suggestionCheckSum, - isSuggestion + isSuggestion, + markdownClassName }: MarkdownViewerProps) { const navigate = useNavigate() const [isOpen, setIsOpen] = useState(false) @@ -151,10 +155,10 @@ export function MarkdownViewer({ return ( <Wrapper> - <div className="m-auto max-w-[836px]" ref={ref} style={styles}> + <div ref={ref} style={styles}> <MarkdownPreview source={source} - className="prose prose-invert" + className={cn('prose prose-invert', markdownClassName)} rehypeRewrite={rehypeRewrite} rehypePlugins={[ rehypeSanitize, diff --git a/packages/ui/src/components/node-group.tsx b/packages/ui/src/components/node-group.tsx index dabd6c1969..b587479f85 100644 --- a/packages/ui/src/components/node-group.tsx +++ b/packages/ui/src/components/node-group.tsx @@ -59,12 +59,8 @@ function Content({ children }: { children: ReactNode }) { function Connector({ first, last, className }: { first?: boolean; last?: boolean; className?: string }) { return ( <div - className={cn( - 'absolute bottom-0 left-[11px] top-0 z-10 w-1', - { 'top-3': first }, - { 'bottom-8': last }, - className - )} + className={cn('absolute bottom-0 left-2.5 top-0 z-10 w-1', { 'top-3': first }, { 'bottom-8': last }, className)} + data-connector > <span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-borders-4" /> </div> diff --git a/packages/ui/src/icons/attachment-image.svg b/packages/ui/src/icons/attachment-image.svg new file mode 100644 index 0000000000..adc55501d6 --- /dev/null +++ b/packages/ui/src/icons/attachment-image.svg @@ -0,0 +1,12 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_24053_167006)"> +<path d="M0.5 12.5L3.5 9.5L5.5 11.5L10.5 6.5L15.5 11.5" stroke="currentColor" style="stroke:currentColor;stroke:color(display-p3 0.5760 0.5760 0.6240);stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M14 15.5H2C1.60218 15.5 1.22064 15.342 0.93934 15.0607C0.658035 14.7794 0.5 14.3978 0.5 14V2C0.5 1.60218 0.658035 1.22064 0.93934 0.93934C1.22064 0.658035 1.60218 0.5 2 0.5H14C14.3978 0.5 14.7794 0.658035 15.0607 0.93934C15.342 1.22064 15.5 1.60218 15.5 2V14C15.5 14.3978 15.342 14.7794 15.0607 15.0607C14.7794 15.342 14.3978 15.5 14 15.5Z" stroke="currentColor" style="stroke:currentColor;stroke:color(display-p3 0.5760 0.5760 0.6240);stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M5 6.5C5.82843 6.5 6.5 5.82843 6.5 5C6.5 4.17157 5.82843 3.5 5 3.5C4.17157 3.5 3.5 4.17157 3.5 5C3.5 5.82843 4.17157 6.5 5 6.5Z" stroke="currentColor" style="stroke:currentColor;stroke:color(display-p3 0.5760 0.5760 0.6240);stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<clipPath id="clip0_24053_167006"> +<rect width="16" height="16" fill="currentColor"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui/src/icons/attachment.svg b/packages/ui/src/icons/attachment.svg index da66fdcf2c..0f8d07b6fe 100644 --- a/packages/ui/src/icons/attachment.svg +++ b/packages/ui/src/icons/attachment.svg @@ -1,10 +1,10 @@ -<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_17619_89256)"> -<path d="M8.85683 5.35812L5.85183 8.36312C5.16883 9.04612 5.16883 10.1541 5.85183 10.8381C6.53483 11.5211 7.64283 11.5211 8.32683 10.8381L12.3928 6.77212C13.7598 5.40512 13.7598 3.18912 12.3928 1.82212C11.0258 0.455125 8.80983 0.455125 7.44283 1.82212L3.02383 6.24212C0.973828 8.29212 0.973828 11.6161 3.02383 13.6671C5.07383 15.7171 8.39783 15.7171 10.4488 13.6671L14.5138 9.60113" stroke="#AEAEB7" stroke-width="1.14286" stroke-linecap="round" stroke-linejoin="round"/> +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_23746_174254)"> +<path d="M8.85683 5.35812L5.85183 8.36312C5.16883 9.04612 5.16883 10.1541 5.85183 10.8381C6.53483 11.5211 7.64283 11.5211 8.32683 10.8381L12.3928 6.77212C13.7598 5.40512 13.7598 3.18912 12.3928 1.82212C11.0258 0.455125 8.80983 0.455125 7.44283 1.82212L3.02383 6.24212C0.973828 8.29212 0.973828 11.6161 3.02383 13.6671C5.07383 15.7171 8.39783 15.7171 10.4488 13.6671L14.5138 9.60113" stroke="currentColor" style="stroke:currentColor;stroke:color(display-p3 0.6820 0.6820 0.7180);stroke-opacity:1;" stroke-width="1.14286" stroke-linecap="round" stroke-linejoin="round"/> </g> <defs> -<clipPath id="clip0_17619_89256"> -<rect width="16" height="16" /> +<clipPath id="clip0_23746_174254"> +<rect width="16" height="16" fill="currentColor" style="fill:currentColor;fill-opacity:1;"/> </clipPath> </defs> </svg> diff --git a/packages/ui/src/icons/bold.svg b/packages/ui/src/icons/bold.svg index 4f972d6a29..6d773b8af5 100644 --- a/packages/ui/src/icons/bold.svg +++ b/packages/ui/src/icons/bold.svg @@ -1 +1,4 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.714" d="M3.502 1.492h5a3.001 3.001 0 0 1 0 6h-5zM3.502 7.492h5.5c1.932 0 3.5 1.568 3.5 3.5s-1.568 3.5-3.5 3.5h-5.5z"/></svg> \ No newline at end of file +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M3.50195 1.49219H8.50195C10.158 1.49219 11.502 2.83619 11.502 4.49219C11.502 6.14819 10.158 7.49219 8.50195 7.49219H3.50195V1.49219Z" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-width="1.71429" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M3.50195 7.49219H9.00195C10.934 7.49219 12.502 9.06019 12.502 10.9922C12.502 12.9242 10.934 14.4922 9.00195 14.4922H3.50195V7.49219Z" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-width="1.71429" stroke-linecap="round" stroke-linejoin="round"/> +</svg> diff --git a/packages/ui/src/icons/code.svg b/packages/ui/src/icons/code.svg index 71d93961e6..9e13fb0006 100644 --- a/packages/ui/src/icons/code.svg +++ b/packages/ui/src/icons/code.svg @@ -1,11 +1,11 @@ -<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"> -<g clip-path="url(#clip0_17619_89259)"> -<path d="M5.5 2.99219L0.5 7.99219L5.5 12.9922" stroke="#AEAEB7" stroke-width="1.14286" stroke-linecap="round" stroke-linejoin="round"/> -<path d="M10.5 12.9922L15.5 7.99219L10.5 2.99219" stroke="#AEAEB7" stroke-width="1.14286" stroke-linecap="round" stroke-linejoin="round"/> +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_23746_174257)"> +<path d="M5.5 2.99219L0.5 7.99219L5.5 12.9922" stroke="currentColor" style="stroke:currentColor;stroke:color(display-p3 0.6820 0.6820 0.7180);stroke-opacity:1;" stroke-width="1.14286" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M10.5 12.9922L15.5 7.99219L10.5 2.99219" stroke="currentColor" style="stroke:currentColor;stroke:color(display-p3 0.6820 0.6820 0.7180);stroke-opacity:1;" stroke-width="1.14286" stroke-linecap="round" stroke-linejoin="round"/> </g> <defs> -<clipPath id="clip0_17619_89259"> -<rect width="16" height="16" fill="currentColor"/> +<clipPath id="clip0_23746_174257"> +<rect width="16" height="16" fill="currentColor" style="fill:currentColor;fill-opacity:1;"/> </clipPath> </defs> </svg> diff --git a/packages/ui/src/icons/collapse-comment.svg b/packages/ui/src/icons/collapse-comment.svg new file mode 100644 index 0000000000..0a8c44f474 --- /dev/null +++ b/packages/ui/src/icons/collapse-comment.svg @@ -0,0 +1,16 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_23746_175034)"> +<path d="M8 1V5.5M8 5.5L10 3.5M8 5.5L6 3.5" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M8 15V10.5M8 10.5L10 12.5M8 10.5L6 12.5" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M1 8H2.25" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M4.1875 8H5.4375" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M7.375 8H8.625" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M10.5625 8H11.8125" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M13.75 8H15" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +</g> +<defs> +<clipPath id="clip0_23746_175034"> +<rect width="16" height="16" fill="currentColor" style="fill:currentColor;fill-opacity:1;"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui/src/icons/expand-comment.svg b/packages/ui/src/icons/expand-comment.svg new file mode 100644 index 0000000000..d83db819fd --- /dev/null +++ b/packages/ui/src/icons/expand-comment.svg @@ -0,0 +1,16 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_23746_175137)"> +<path d="M8 5.5V1M8 1L10 3M8 1L6 3" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M8 10.5V15M8 15L10 13M8 15L6 13" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M1 8H2.25" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M4.1875 8H5.4375" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M7.375 8H8.625" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M10.5625 8H11.8125" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +<path d="M13.75 8H15" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round"/> +</g> +<defs> +<clipPath id="clip0_23746_175137"> +<rect width="16" height="16" fill="currentColor" style="fill:currentColor;fill-opacity:1;"/> +</clipPath> +</defs> +</svg> diff --git a/packages/ui/src/icons/suggestion.svg b/packages/ui/src/icons/suggestion.svg new file mode 100644 index 0000000000..c2c132f217 --- /dev/null +++ b/packages/ui/src/icons/suggestion.svg @@ -0,0 +1,13 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_23746_174295)"> +<path d="M5.5 11.5H10.5" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M5.5 6.5H10.5" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M8 4L8 9" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +<path d="M9.5 0.5H1.5V15.5H14.5V5.5L9.5 0.5Z" stroke="currentColor" style="stroke:currentColor;stroke-opacity:1;" stroke-linecap="round" stroke-linejoin="round"/> +</g> +<defs> +<clipPath id="clip0_23746_174295"> +<rect width="16" height="16" fill="currentColor" style="fill:currentColor;fill-opacity:1;"/> +</clipPath> +</defs> +</svg> \ No newline at end of file diff --git a/packages/ui/src/views/layouts/PullRequestLayout.tsx b/packages/ui/src/views/layouts/PullRequestLayout.tsx index d58ab5ca24..78580b956d 100644 --- a/packages/ui/src/views/layouts/PullRequestLayout.tsx +++ b/packages/ui/src/views/layouts/PullRequestLayout.tsx @@ -62,28 +62,29 @@ const PullRequestLayout: React.FC<PullRequestLayoutProps> = ({ <NavLink to={PullRequestTabsKeys.CONVERSATION}> {({ isActive }) => ( <TabsTrigger - className="gap-x-1" + className="gap-x-1.5" value={PullRequestTabsKeys.CONVERSATION} data-state={isActive ? 'active' : 'inactive'} > - <Icon size={14} name="comments" /> - {t('views:pullRequests.conversation')} - <Badge variant="outline" size="xs"> - {pullRequest?.stats?.conversations || 0} - </Badge> + <div className="flex items-center gap-x-1"> + <Icon size={14} name="comments" /> + {t('views:pullRequests.conversation')} + </div> </TabsTrigger> )} </NavLink> <NavLink to={PullRequestTabsKeys.COMMITS}> {({ isActive }) => ( <TabsTrigger - className="gap-x-1" + className="gap-x-1.5" value={PullRequestTabsKeys.COMMITS} data-state={isActive ? 'active' : 'inactive'} > - <Icon size={14} name="tube-sign" /> - {t('views:repos.commits')} - <Badge variant="outline" size="xs"> + <div className="flex items-center gap-x-1"> + <Icon size={14} name="tube-sign" /> + {t('views:repos.commits')} + </div> + <Badge variant="outline" size="xs" borderRadius="base"> {pullRequest?.stats?.commits} </Badge> </TabsTrigger> @@ -92,13 +93,15 @@ const PullRequestLayout: React.FC<PullRequestLayoutProps> = ({ <NavLink to={PullRequestTabsKeys.CHANGES}> {({ isActive }) => ( <TabsTrigger - className="gap-x-1" + className="gap-x-1.5" value={PullRequestTabsKeys.CHANGES} data-state={isActive ? 'active' : 'inactive'} > - <Icon size={14} name="changes" /> - {t('views:pullRequests.changes')} - <Badge variant="outline" size="xs"> + <div className="flex items-center gap-x-1"> + <Icon size={14} name="changes" /> + {t('views:pullRequests.changes')} + </div> + <Badge variant="outline" size="xs" borderRadius="base"> {pullRequest?.stats?.files_changed} </Badge> </TabsTrigger> diff --git a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-tab-trigger-item.tsx b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-tab-trigger-item.tsx index 6a0ac2c504..7caa92f4df 100644 --- a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-tab-trigger-item.tsx +++ b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-tab-trigger-item.tsx @@ -3,8 +3,6 @@ import { FC } from 'react' import { Badge } from '@components/badge' import { Icon, IconProps } from '@components/icon' import { TabsTrigger } from '@components/tabs' -import { Text } from '@components/text' -import { Layout } from '@views/layouts/layout' interface TabTriggerItemProps { value: string @@ -15,18 +13,16 @@ interface TabTriggerItemProps { const TabTriggerItem: FC<TabTriggerItemProps> = ({ value, icon, label, badgeCount }) => { return ( - <TabsTrigger value={value} className="data-[state=active]:bg-background-1"> - <Layout.Horizontal className="items-center" gap="gap-x-1.5"> - <div> - <Icon size={16} name={icon as IconProps['name']} /> - </div> - <Text size={2}>{label}</Text> - {badgeCount !== undefined && ( - <Badge variant="outline" size="xs"> - {badgeCount} - </Badge> - )} - </Layout.Horizontal> + <TabsTrigger value={value} className="gap-x-1.5"> + <div className="flex items-center gap-x-1"> + <Icon size={14} name={icon as IconProps['name']} /> + <span>{label}</span> + </div> + {badgeCount !== undefined && ( + <Badge variant="outline" size="xs" borderRadius="base"> + {badgeCount} + </Badge> + )} </TabsTrigger> ) } diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx index 6ab3a73bcd..8cb4dd52af 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-diff-viewer.tsx @@ -8,7 +8,7 @@ import { TranslationStore, TypesPullReqActivity } from '@/views' -import { Avatar, AvatarFallback, Layout, Text } from '@components/index' +import { Avatar, AvatarFallback, Layout } from '@components/index' import { DiffFile, DiffModeEnum, DiffView, DiffViewProps, SplitSide } from '@git-diff-view/react' import { getInitials, timeAgo } from '@utils/utils' import { DiffBlock } from 'diff2html/lib/types' @@ -49,7 +49,6 @@ interface PullRequestDiffviewerProps { useTranslationStore: () => TranslationStore commentId?: string onCopyClick?: (commentId?: number) => void - onCommentSaveAndStatusChange?: (comment: string, status: string, parentId?: number) => void suggestionsBatch?: CommitSuggestion[] onCommitSuggestion?: (suggestion: CommitSuggestion) => void addSuggestionToBatch?: (suggestion: CommitSuggestion) => void @@ -79,7 +78,6 @@ const PullRequestDiffViewer = ({ useTranslationStore, commentId, onCopyClick, - onCommentSaveAndStatusChange, suggestionsBatch, onCommitSuggestion, addSuggestionToBatch, @@ -342,7 +340,7 @@ const PullRequestDiffViewer = ({ if (!threads) return <></> return ( - <div className="w- rounded border bg-background"> + <div className="rounded border bg-background"> {threads.map(thread => { const parent = thread.parent const componentId = `activity-code-${parent?.id}` @@ -356,20 +354,28 @@ const PullRequestDiffViewer = ({ parentCommentId={parent.id} handleSaveComment={handleSaveComment} isLast={true} - contentClassName="px-4 py-2 w-[calc(100%-38px)]" + contentClassName="w-[calc(100%-38px)]" header={[]} currentUser={currentUser} isComment - replyBoxClassName="py-4" + replyBoxClassName="p-4" hideReplyHere={hideReplyHeres[parent?.id]} setHideReplyHere={state => toggleReplyBox(state, parent?.id)} isResolved={!!parent.payload?.resolved} toggleConversationStatus={toggleConversationStatus} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} onQuoteReply={handleQuoteReply} quoteReplyText={quoteReplies[parent.id]?.text || ''} + contentHeader={ + !!parent.payload?.resolved && ( + <div className="flex items-center gap-x-1"> + {/* TODO: need to identify the author who resolved the conversation */} + <span className="font-medium text-foreground-8">{parent.author}</span> + <span className="text-foreground-4">marked this conversation as resolved</span> + </div> + ) + } content={ - <div className="flex-col"> + <div className="flex-col px-4 pt-4"> <PullRequestTimelineItem titleClassName="!flex max-w-full" parentCommentId={parent.id} @@ -389,9 +395,7 @@ const PullRequestDiffViewer = ({ icon={ <Avatar className="size-6 rounded-full p-0"> <AvatarFallback> - <Text size={1} color="tertiaryBackground"> - {parentInitials} - </Text> + <span className="text-12 text-foreground-3">{parentInitials}</span> </AvatarFallback> </Avatar> } @@ -399,12 +403,12 @@ const PullRequestDiffViewer = ({ { name: parent.author, description: ( - <Layout.Horizontal> - <span className="text-foreground-3">{timeAgo(parent?.created as number)}</span> + <Layout.Horizontal className="text-foreground-4"> + <span>{timeAgo(parent?.created as number)}</span> {parent?.deleted ? ( <> - <span className="text-foreground-3"> | </span> - <span className="text-foreground-3">{t('views:pullRequests.deleted')} </span> + <span> | </span> + <span>{t('views:pullRequests.deleted')} </span> </> ) : null} </Layout.Horizontal> @@ -474,9 +478,7 @@ const PullRequestDiffViewer = ({ icon={ <Avatar className="size-6 rounded-full p-0"> <AvatarFallback> - <Text size={1} color="tertiaryBackground"> - {replyInitials} - </Text> + <span className="text-12 text-foreground-3">{replyInitials}</span> </AvatarFallback> </Avatar> } @@ -484,12 +486,12 @@ const PullRequestDiffViewer = ({ { name: reply.author, description: ( - <Layout.Horizontal> - <span className="text-foreground-3">{timeAgo(reply?.created as number)}</span> + <Layout.Horizontal className="text-foreground-4"> + <span>{timeAgo(reply?.created as number)}</span> {reply?.deleted ? ( <> - <span className="text-foreground-3"> | </span> - <span className="text-foreground-3">{t('views:pullRequests.deleted')} </span> + <span> | </span> + <span>{t('views:pullRequests.deleted')} </span> </> ) : null} </Layout.Horizontal> @@ -505,7 +507,6 @@ const PullRequestDiffViewer = ({ <PullRequestCommentBox handleUpload={handleUpload} isEditMode - isResolved={!!parent?.payload?.resolved} onSaveComment={() => { if (reply?.id) { updateComment?.(reply?.id, editComments[replyComponentId]) @@ -520,7 +521,6 @@ const PullRequestDiffViewer = ({ setComment={text => setEditComments(prev => ({ ...prev, [replyComponentId]: text })) } - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} /> ) : ( <PRCommentView diff --git a/packages/ui/src/views/repo/pull-request/details/components/changes/pull-request-changes.tsx b/packages/ui/src/views/repo/pull-request/details/components/changes/pull-request-changes.tsx index 82c845acad..2992b4cd10 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/changes/pull-request-changes.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/changes/pull-request-changes.tsx @@ -208,7 +208,6 @@ const PullRequestAccordion: React.FC<{ commentId, autoExpand, onCopyClick, - onCommentSaveAndStatusChange, suggestionsBatch, onCommitSuggestion, addSuggestionToBatch, @@ -384,7 +383,6 @@ const PullRequestAccordion: React.FC<{ useTranslationStore={useTranslationStore} commentId={commentId} onCopyClick={onCopyClick} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} onCommitSuggestion={onCommitSuggestion} addSuggestionToBatch={addSuggestionToBatch} suggestionsBatch={suggestionsBatch} diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx index 46ab1c50ee..ac2946391a 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx @@ -1,9 +1,11 @@ -import { useMemo, useState } from 'react' +import { Fragment, useMemo, useRef, useState } from 'react' import { Avatar, AvatarFallback, Button, + Icon, + IconProps, MarkdownViewer, Tabs, TabsContent, @@ -14,16 +16,17 @@ import { import { cn } from '@utils/cn' import { getInitials } from '@utils/stringUtils' +import { ToolbarAction } from '../../pull-request-details-types' import { handleFileDrop, handlePaste } from '../../pull-request-utils' -// TODO: add back when functionality is added -// import { ToolbarAction } from '../../pull-request-details-types' -// interface ToolbarItem { -// icon: IconProps['name'] -// action: ToolbarAction -// title?: string -// size?: number -// } +interface ToolbarItem { + icon: IconProps['name'] + action: ToolbarAction + title?: string + size?: number + onClick?: () => void +} + interface PullRequestCommentBoxProps { onSaveComment: (comment: string) => void comment: string @@ -36,14 +39,15 @@ interface PullRequestCommentBoxProps { onCommentSubmit?: () => void inReplyMode?: boolean isEditMode?: boolean - hideAvatar?: boolean onCancelClick?: () => void - isResolved?: boolean - onCommentSaveAndStatusChange?: (comment: string, status: string, parentId?: number) => void - parentCommentId?: number handleUpload?: (blob: File, setMarkdownContent: (data: string) => void) => void } +const TABS_KEYS = { + WRITE: 'write', + PREVIEW: 'preview' +} + // TODO: will have to eventually implement a commenting and reply system similiar to gitness const PullRequestCommentBox = ({ onSaveComment, @@ -53,13 +57,13 @@ const PullRequestCommentBox = ({ comment, setComment, isEditMode, - hideAvatar, - isResolved, - onCommentSaveAndStatusChange, - parentCommentId, handleUpload }: PullRequestCommentBoxProps) => { const [__file, setFile] = useState<File>() + const [activeTab, setActiveTab] = useState<typeof TABS_KEYS.WRITE | typeof TABS_KEYS.PREVIEW>(TABS_KEYS.WRITE) + const fileInputRef = useRef<HTMLInputElement | null>(null) + const [isDragging, setIsDragging] = useState(false) + const dropZoneRef = useRef<HTMLDivElement>(null) const handleSaveComment = () => { if (comment.trim()) { @@ -71,7 +75,6 @@ const PullRequestCommentBox = ({ const avatar = useMemo(() => { return ( <Avatar size="6"> - {/* <AvatarImage src={AvatarUrl} /> */} <AvatarFallback> <span className="text-12 text-foreground-3">{getInitials(currentUser || '')}</span> </AvatarFallback> @@ -79,138 +82,177 @@ const PullRequestCommentBox = ({ ) }, [currentUser]) - // TODO: add back when functionality is added - // const toolbar: ToolbarItem[] = useMemo(() => { - // const initial: ToolbarItem[] = [] - // return [ - // ...initial, - // { icon: 'header', action: ToolbarAction.HEADER }, - // { icon: 'bold', action: ToolbarAction.BOLD }, - // { icon: 'italicize', action: ToolbarAction.ITALIC }, - // { icon: 'attachment', action: ToolbarAction.UPLOAD }, - // { icon: 'list', action: ToolbarAction.UNORDER_LIST }, - // { icon: 'checklist', action: ToolbarAction.CHECK_LIST }, - // { icon: 'code', action: ToolbarAction.CODE_BLOCK } - // ] - // }, []) const handleUploadCallback = (file: File) => { setFile(file) handleUpload?.(file, setComment) } + + const handleFileSelect = () => { + fileInputRef.current?.click() + } + + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + handleUploadCallback(file) + } + } + + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.currentTarget === dropZoneRef.current) { + setIsDragging(true) + } + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.currentTarget === dropZoneRef.current && !e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragging(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + handleDropForUpload(e) + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleDropForUpload = async (event: any) => { handleFileDrop(event, handleUploadCallback) } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handlePasteForUpload = (event: { preventDefault: () => void; clipboardData: any }) => { + + const handlePasteForUpload = (event: React.ClipboardEvent) => { handlePaste(event, handleUploadCallback) } + // TODO: add the remaining required logic for the toolbar + const toolbar: ToolbarItem[] = useMemo(() => { + const initial: ToolbarItem[] = [] + return [ + ...initial, + { icon: 'suggestion', action: ToolbarAction.SUGGESTION }, + { icon: 'header', action: ToolbarAction.HEADER }, + { icon: 'bold', action: ToolbarAction.BOLD }, + { icon: 'italicize', action: ToolbarAction.ITALIC }, + { icon: 'attachment', action: ToolbarAction.UPLOAD, onClick: handleFileSelect }, + { icon: 'list', action: ToolbarAction.UNORDER_LIST }, + { icon: 'checklist', action: ToolbarAction.CHECK_LIST }, + { icon: 'code', action: ToolbarAction.CODE_BLOCK } + ] + }, []) + + const handleTabChange = (tab: typeof TABS_KEYS.WRITE | typeof TABS_KEYS.PREVIEW) => { + setActiveTab(tab) + } + return ( - <div className="flex items-start space-x-4"> - {(!isEditMode || !hideAvatar) && avatar} + <div className="flex items-start gap-x-3"> + {!inReplyMode && avatar} + <div - className={cn('min-w-0 flex-1 px-4 pb-5 pt-1.5', { + className={cn('pb-5 pt-1.5 px-4 flex-1 bg-background-2 border-border-1', { 'border rounded-md': !inReplyMode || isEditMode, - 'border-t ': inReplyMode + 'border-t': inReplyMode })} > - <Tabs variant="tabnav" defaultValue="write"> + <Tabs variant="tabnav" defaultValue={TABS_KEYS.WRITE} value={activeTab} onValueChange={handleTabChange}> <TabsList className="relative left-1/2 w-[calc(100%+32px)] -translate-x-1/2 px-4"> - <TabsTrigger value="write">Write</TabsTrigger> - <TabsTrigger value="preview">Preview</TabsTrigger> + <TabsTrigger className="data-[state=active]:bg-background-2" value={TABS_KEYS.WRITE}> + Write + </TabsTrigger> + <TabsTrigger className="data-[state=active]:bg-background-2" value={TABS_KEYS.PREVIEW}> + Preview + </TabsTrigger> </TabsList> - <TabsContent className="mt-4" value="write"> + <TabsContent className="mt-4" value={TABS_KEYS.WRITE}> <div - onDrop={e => { - handleDropForUpload(e) - }} - onPaste={handlePasteForUpload} - className="relative gap-y-1" - onDragOver={event => { - event.preventDefault() - }} + className="relative" + onDrop={handleDrop} + onDragOver={e => e.preventDefault()} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + ref={dropZoneRef} > <Textarea - onDrop={e => { - handleDropForUpload(e) - }} - onPaste={e => { - if (e.clipboardData.files.length > 0) { - handlePasteForUpload(e) - } else { - const pastedText = e.clipboardData.getData('Text') - setComment(comment + pastedText) - } - }} className="min-h-24 p-3 pb-10" autoFocus={!!inReplyMode} placeholder="Add your comment here" value={comment} onChange={e => setComment(e.target.value)} + onPaste={e => { + if (e.clipboardData.files.length > 0) { + handlePasteForUpload(e) + } + }} resizable /> - <p className="pt-1 text-foreground-4"> - Attach images & videos by dragging and dropping,selecting or pasting them. - </p> + {isDragging && ( + <div className="absolute inset-1 cursor-copy rounded-sm border border-dashed border-borders-2" /> + )} - {/* TODO: add back when functionality is implemented */} - {/* <div className="absolute pb-2 pt-1 px-1 bottom-px bg-background-1 left-1/2 w-[calc(100%-2px)] -translate-x-1/2 rounded"> + <div className="absolute -ml-0.5 bottom-px flex left-1/2 w-[calc(100%-16px)] -translate-x-1/2 bg-background-2 items-center pb-2 pt-1"> {toolbar.map((item, index) => { + const isFirst = index === 0 return ( - <Button key={`${comment}-${index}`} size="icon" variant="ghost"> - <Icon name={item.icon} /> - </Button> + <Fragment key={`${comment}-${index}`}> + <Button size="icon" variant="ghost" onClick={item?.onClick}> + <Icon className="text-icons-9" name={item.icon} /> + </Button> + {isFirst && <div className="h-4 w-px bg-borders-2" />} + </Fragment> ) })} - </div> */} + </div> </div> </TabsContent> - <TabsContent className="mt-4" value="preview"> + <TabsContent className="mt-4" value={TABS_KEYS.PREVIEW}> <div className="min-h-24"> - {comment ? <MarkdownViewer source={comment} /> : <span>Nothing to preview</span>} + {comment ? ( + <MarkdownViewer markdownClassName="!bg-background-2" source={comment} /> + ) : ( + <span>Nothing to preview</span> + )} </div> </TabsContent> </Tabs> - <div className="mt-4 flex items-center gap-x-3"> - {!inReplyMode && !isEditMode ? ( - <Button onClick={handleSaveComment}>Comment</Button> - ) : isEditMode ? ( - <> - <Button onClick={handleSaveComment}>Save</Button> - <Button variant="outline" onClick={onCancelClick}> - Cancel - </Button> - </> - ) : ( - <></> - )} - {inReplyMode && ( - <> - {isEditMode ? ( - <Button onClick={handleSaveComment}>Save</Button> - ) : ( - <Button onClick={handleSaveComment}>Reply</Button> - )} + <div className="mt-4 flex items-center justify-between"> + {activeTab === TABS_KEYS.WRITE && ( + <div> + <input type="file" ref={fileInputRef} className="hidden" onChange={handleFileChange} /> <Button - variant={'outline'} - onClick={() => { - if (comment.trim()) { - onCommentSaveAndStatusChange?.(comment.trim(), isResolved ? 'active' : 'resolved', parentCommentId) - onCancelClick?.() - } - }} + className="gap-x-2 px-2.5 font-normal text-foreground-3 hover:bg-background-8" + variant="custom" + onClick={handleFileSelect} > - {isResolved ? 'Reply & Reactivate' : 'Reply & Resolve'} + <Icon size={16} name="attachment-image" /> + <span>Drag & drop, select, or paste to attach files</span> </Button> + </div> + )} + + <div className="flex gap-x-3 ml-auto"> + {(inReplyMode || isEditMode) && ( <Button variant="outline" onClick={onCancelClick}> Cancel </Button> - </> - )} + )} + + {isEditMode ? ( + <Button onClick={handleSaveComment}>Save</Button> + ) : ( + <Button onClick={handleSaveComment}>Comment</Button> + )} + </div> </div> </div> </div> diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-description-box.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-description-box.tsx index 163532217b..aed895b673 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-description-box.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-description-box.tsx @@ -63,24 +63,19 @@ const PullRequestDescBox: React.FC<PullRequestDescBoxProps> = ({ { avatar: ( <Avatar size="6"> - {/* <AvatarImage src={AvatarUrl} /> */} <AvatarFallback> - <Text size={0} color="tertiaryBackground"> - {getInitials(author || '')} - </Text> + <span className="text-12 text-foreground-3">{getInitials(author || '')}</span> </AvatarFallback> </Avatar> ), name: author, - // TODO: make pr num clickable? + // TODO: pr number must be a link description: ( - <Text size={2} className="gap-x-2" color="tertiaryBackground"> - {`created pull request`} - <Text size={2} className="pl-1"> - {`${prNum} `} - </Text> + <span className="flex gap-x-1"> + created pull request + <span className="text-foreground-8">{prNum}</span> {formattedTime} - </Text> + </span> ) } ]} @@ -93,21 +88,17 @@ const PullRequestDescBox: React.FC<PullRequestDescBoxProps> = ({ <PullRequestCommentBox isEditMode handleUpload={handleUpload} - isResolved={undefined} onSaveComment={() => { if (title && description) { handleUpdateDescription(title, comment || '') setEdit(false) } }} - currentUser={undefined} onCancelClick={() => { setEdit(false) }} comment={comment} setComment={setComment} - onCommentSaveAndStatusChange={undefined} - parentCommentId={undefined} /> ) : ( <Text size={2} color="primary"> diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx index 9d48354ba5..4bc0f498b0 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from 'react' -import { Avatar, AvatarFallback, Icon, Layout, Text } from '@components/index' +import { Avatar, AvatarFallback, Icon, Layout } from '@components/index' import { DiffModeEnum } from '@git-diff-view/react' import { getInitials } from '@utils/stringUtils' import { timeAgo } from '@utils/utils' @@ -50,7 +50,6 @@ interface PullRequestOverviewProps extends RoutingProps { repoId: string diffData?: { text: string; numAdditions?: number; numDeletions?: number; data?: string; title: string; lang: string } onCopyClick: (commentId?: number) => void - onCommentSaveAndStatusChange?: (comment: string, status: string, parentId?: number) => void suggestionsBatch: CommitSuggestion[] onCommitSuggestion: (suggestion: CommitSuggestion) => void addSuggestionToBatch: (suggestion: CommitSuggestion) => void @@ -82,7 +81,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ useTranslationStore, onCopyClick, handleUpload, - onCommentSaveAndStatusChange, suggestionsBatch, onCommitSuggestion, addSuggestionToBatch, @@ -93,7 +91,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ toCommitDetails }) => { const { t } = useTranslationStore() - const { // mode, // setMode, @@ -254,7 +251,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ currentUser={currentUser?.display_name} replyBoxClassName="p-4" toggleConversationStatus={toggleConversationStatus} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} isResolved={!!payload?.resolved} icon={<Icon name="pr-review" size={12} />} isLast={(data && data?.length - 1 === index) ?? false} @@ -264,13 +260,11 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ { avatar: ( <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} - </Text> + </span> </AvatarFallback> </Avatar> ), @@ -279,13 +273,9 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ description: payload?.created && `reviewed ${timeAgo(payload?.created)}` } ]} + contentHeader={<span>{(payload?.code_comment as PayloadCodeComment)?.path}</span>} content={ - <div className="flex flex-col pt-2"> - <div className="flex w-full items-center justify-between px-4 pb-2"> - <Text size={3} color="primary"> - {(payload?.code_comment as PayloadCodeComment)?.path} - </Text> - </div> + <div className="flex flex-col"> {startingLine ? ( <div className="bg-[--diff-hunk-lineNumber--]"> <div className="ml-16 w-full px-8 py-1">{startingLine}</div> @@ -334,13 +324,10 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ contentClassName="border-transparent" replyBoxClassName="p-4" toggleConversationStatus={toggleConversationStatus} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} icon={ <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials( ( @@ -348,7 +335,7 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ ?.author as PayloadAuthor )?.display_name || '' )} - </Text> + </span> </AvatarFallback> </Avatar> } @@ -360,16 +347,12 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ )?.display_name, // TODO: fix comment to tell between comment or code comment? description: ( - <Layout.Horizontal> - <span className="text-foreground-3"> - {timeAgo((commentItem as unknown as PayloadCreated)?.created)} - </span> + <Layout.Horizontal className="text-foreground-4"> + <span>{timeAgo((commentItem as unknown as PayloadCreated)?.created)}</span> {commentItem?.deleted ? ( <> - <span className="text-foreground-3"> | </span> - <span className="text-foreground-3"> - {t('views:pullRequests.deleted')}{' '} - </span> + <span> | </span> + <span>{t('views:pullRequests.deleted')}</span> </> ) : null} </Layout.Horizontal> @@ -385,7 +368,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ <PullRequestCommentBox isEditMode handleUpload={handleUpload} - isResolved={!!payload?.resolved} onSaveComment={() => { if (commentItem?.id) { handleUpdateComment?.(commentItem?.id, editComments[componentId]) @@ -400,8 +382,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ setComment={text => setEditComments(prev => ({ ...prev, [componentId]: text })) } - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} - parentCommentId={payload?.id} /> ) : ( <PRCommentView @@ -442,18 +422,15 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ replyBoxClassName="p-4" isResolved={!!payload?.resolved} toggleConversationStatus={toggleConversationStatus} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} header={[ { avatar: ( <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} - </Text> + </span> </AvatarFallback> </Avatar> ), @@ -485,7 +462,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ onQuoteReply={handleQuoteReply} parentCommentId={payload?.id} toggleConversationStatus={toggleConversationStatus} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} titleClassName="!flex max-w-full" currentUser={currentUser?.display_name} isLast={commentItems.length - 1 === idx} @@ -502,10 +478,8 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ key={`${commentItem.id}-${commentItem.author}-pr-comment`} icon={ <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials( ( @@ -513,7 +487,7 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ ?.author as PayloadAuthor ).display_name || '' )} - </Text> + </span> </AvatarFallback> </Avatar> } @@ -549,7 +523,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ <PullRequestCommentBox handleUpload={handleUpload} isEditMode - isResolved={!!payload?.resolved} onSaveComment={() => { if (commentItem?.id) { handleUpdateComment?.(commentItem?.id, editComments[componentId]) @@ -562,8 +535,6 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ }} comment={editComments[componentId]} setComment={text => setEditComments(prev => ({ ...prev, [componentId]: text }))} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} - parentCommentId={payload?.id} /> ) : ( <PRCommentView diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-system-comments.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-system-comments.tsx index c15b5d1068..992b97fe7a 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-system-comments.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-system-comments.tsx @@ -66,13 +66,11 @@ const PullRequestSystemComments: React.FC<SystemCommentProps> = ({ { avatar: ( <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} - </Text> + </span> </AvatarFallback> </Avatar> ), @@ -101,9 +99,7 @@ const PullRequestSystemComments: React.FC<SystemCommentProps> = ({ const authorAvatar = ( <Avatar className="size-6 rounded-full p-0"> <AvatarFallback> - <Text size={1} color="tertiaryBackground"> - {getInitials(author?.display_name || '')} - </Text> + <span className="text-12 text-foreground-3">{getInitials(author?.display_name || '')}</span> </AvatarFallback> </Avatar> ) @@ -145,13 +141,11 @@ const PullRequestSystemComments: React.FC<SystemCommentProps> = ({ { avatar: ( <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} - </Text> + </span> </AvatarFallback> </Avatar> ), @@ -192,13 +186,11 @@ const PullRequestSystemComments: React.FC<SystemCommentProps> = ({ { avatar: ( <Avatar className="size-6 rounded-full p-0"> - {/* <AvatarImage src={AvatarUrl} /> */} - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> + <span className="text-12 text-foreground-3"> {/* TODO: fix fallback string */} {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} - </Text> + </span> </AvatarFallback> </Avatar> ), diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx index 9fd217d12e..84e9135f1a 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-timeline-item.tsx @@ -1,4 +1,4 @@ -import { FC, memo, ReactNode, useEffect, useState } from 'react' +import { Children, FC, memo, ReactElement, ReactNode, useEffect, useState } from 'react' import { Avatar, AvatarFallback, Button, Card, DropdownMenu, Icon, Input, NodeGroup, Text } from '@/components' import { cn } from '@utils/cn' @@ -16,6 +16,7 @@ interface TimelineItemProps { parentCommentId?: number commentId?: number currentUser?: string + contentHeader?: ReactNode content?: ReactNode icon?: ReactNode isLast: boolean @@ -37,7 +38,6 @@ interface TimelineItemProps { id?: string isResolved?: boolean toggleConversationStatus?: (status: string, parentId?: number) => void - onCommentSaveAndStatusChange?: (comment: string, status: string, parentId?: number) => void data?: string handleUpload?: (blob: File, setMarkdownContent: (data: string) => void) => void onQuoteReply?: (parentId: number, rawText: string) => void @@ -125,16 +125,8 @@ const ItemHeader: FC<ItemHeaderProps> = memo( <div className="inline-flex w-full items-center justify-between gap-1.5"> <div className="inline-flex items-center gap-1.5"> {avatar && <div>{avatar}</div>} - {name && ( - <Text size={2} color="primary" weight="medium"> - {name} - </Text> - )} - {description && ( - <Text size={2} color="tertiaryBackground"> - {description} - </Text> - )} + {name && <span className="text-14 font-medium text-foreground-8">{name}</span>} + {description && <span className="text-14 text-foreground-4">{description}</span>} </div> {selectStatus && ( <div className="justify-end"> @@ -152,6 +144,7 @@ ItemHeader.displayName = 'ItemHeader' const PullRequestTimelineItem: FC<TimelineItemProps> = ({ header, + contentHeader, content, icon, isLast, @@ -175,7 +168,6 @@ const PullRequestTimelineItem: FC<TimelineItemProps> = ({ id, isResolved, toggleConversationStatus, - onCommentSaveAndStatusChange, isNotCodeComment, data, handleUpload, @@ -183,10 +175,42 @@ const PullRequestTimelineItem: FC<TimelineItemProps> = ({ quoteReplyText }) => { const [comment, setComment] = useState('') + const [isExpanded, setIsExpanded] = useState(!isResolved) + useEffect(() => { if (quoteReplyText) setComment(quoteReplyText) }, [quoteReplyText]) + useEffect(() => { + if (isResolved) { + setIsExpanded(false) + } + }, [isResolved]) + + const renderContent = () => { + if (!content) return null + + // Show full content if not resolved or expanded + if (!isResolved || isExpanded) { + return content + } + + // For resolved comments with contentHeader, hide all content when collapsed + if (contentHeader) { + return null + } + + // For resolved comments without contentHeader, show only the first comment + const contentElement = content as ReactElement + if (contentElement.props?.children?.length) { + // If content is an array of comments, take the first one + const [firstComment] = Children.toArray(contentElement.props.children) + return <div className="px-4 pt-4 [&_[data-connector]]:hidden">{firstComment}</div> + } + // If content is a single element, return as is + return content + } + return ( <div id={id}> <NodeGroup.Root> @@ -194,102 +218,122 @@ const PullRequestTimelineItem: FC<TimelineItemProps> = ({ <NodeGroup.Title className={titleClassName}> {/* Ensure that header has at least one item */} {header.length > 0 && ( - <ItemHeader - isDeleted={isDeleted} - onEditClick={onEditClick} - onCopyClick={onCopyClick} - isComment={isComment} - isNotCodeComment={isNotCodeComment} - handleDeleteComment={handleDeleteComment} - commentId={commentId} - {...header[0]} - onQuoteReply={() => { - setHideReplyHere?.(true) - if (parentCommentId) onQuoteReply?.(parentCommentId, data ?? '') - }} - /> + <div className="flex w-full items-center justify-between gap-x-2"> + <ItemHeader + isDeleted={isDeleted} + onEditClick={onEditClick} + onCopyClick={onCopyClick} + isComment={isComment} + isNotCodeComment={isNotCodeComment} + handleDeleteComment={handleDeleteComment} + commentId={commentId} + {...header[0]} + onQuoteReply={() => { + setHideReplyHere?.(true) + if (parentCommentId) onQuoteReply?.(parentCommentId, data ?? '') + }} + /> + {isResolved && !contentHeader && ( + <Button + className="h-auto gap-x-1.5 px-4 font-normal text-foreground-2 hover:text-foreground-8" + variant="custom" + onClick={() => setIsExpanded(prev => !prev)} + > + <Icon name={isExpanded ? 'collapse-comment' : 'expand-comment'} size={14} /> + {isExpanded ? 'Hide resolved' : 'Show resolved'} + </Button> + )} + </div> )} </NodeGroup.Title> {content && ( <NodeGroup.Content> - <Card.Root className={cn('rounded-md bg-transparent', contentClassName)}> + <Card.Root className={cn('rounded-md bg-transparent overflow-hidden shadow-none', contentClassName)}> + {contentHeader && ( + <div + className={cn('flex w-full items-center justify-between p-4 bg-background-2', { + 'pr-1.5': isResolved + })} + > + {contentHeader} + {isResolved && ( + <Button + className="h-auto gap-x-1.5 px-2.5 font-normal text-foreground-2 hover:text-foreground-8" + variant="custom" + onClick={() => setIsExpanded(prev => !prev)} + > + <Icon name={isExpanded ? 'collapse-comment' : 'expand-comment'} size={14} /> + {isExpanded ? 'Hide resolved' : 'Show resolved'} + </Button> + )} + </div> + )} + {isEditMode ? ( <PullRequestCommentBox handleUpload={handleUpload} isEditMode + currentUser={currentUser} onSaveComment={() => { handleSaveComment?.(comment, parentCommentId) setComment('') }} - currentUser={currentUser} onCancelClick={() => { setComment('') }} - isResolved={isResolved} comment={comment} setComment={setComment} /> ) : ( - content + renderContent() )} - {!hideReplySection && ( + {!hideReplySection && (!isResolved || isExpanded) && ( <> {hideReplyHere ? ( - <div className="flex w-full flex-col px-4"> - <PullRequestCommentBox - handleUpload={handleUpload} - inReplyMode - hideAvatar - onSaveComment={() => { - handleSaveComment?.(comment, parentCommentId) - setHideReplyHere?.(false) + <PullRequestCommentBox + handleUpload={handleUpload} + inReplyMode + onSaveComment={() => { + handleSaveComment?.(comment, parentCommentId) + setHideReplyHere?.(false) + }} + onCancelClick={() => { + setHideReplyHere?.(false) + }} + comment={comment} + setComment={setComment} + /> + ) : ( + <div className={cn('flex items-center gap-3 border-t bg-background-2', replyBoxClassName)}> + {currentUser ? ( + <Avatar className="size-6 rounded-full p-0"> + <AvatarFallback> + <span className="text-12 text-foreground-3">{getInitials(currentUser ?? '', 2)}</span> + </AvatarFallback> + </Avatar> + ) : null} + <Input + placeholder="Reply here" + size="md" + onClick={() => { + setHideReplyHere?.(true) }} - currentUser={currentUser} - onCancelClick={() => { - setHideReplyHere?.(false) + onChange={e => { + setComment(e.target.value) }} - comment={comment} - isResolved={isResolved} - setComment={setComment} - parentCommentId={parentCommentId} - onCommentSaveAndStatusChange={onCommentSaveAndStatusChange} /> </div> - ) : ( - <> - <div className={cn('flex items-center gap-3 border-t', replyBoxClassName)}> - {currentUser ? ( - <Avatar className="size-6 rounded-full p-0"> - <AvatarFallback> - <Text size={1} color="tertiaryBackground"> - {getInitials(currentUser ?? '', 2)} - </Text> - </AvatarFallback> - </Avatar> - ) : null} - <Input - placeholder="Reply here" - size="md" - onClick={() => { - setHideReplyHere?.(true) - }} - onChange={e => { - setComment(e.target.value) - }} - /> - </div> - <div className={cn('flex gap-3 border-t', replyBoxClassName)}> - <Button - variant={'outline'} - onClick={() => { - toggleConversationStatus?.(isResolved ? 'active' : 'resolved', parentCommentId) - }} - > - {isResolved ? 'Reactivate' : 'Resolve Conversation'} - </Button> - </div> - </> )} + <div className={cn('flex gap-3 border-t', replyBoxClassName)}> + <Button + variant="outline" + onClick={() => { + toggleConversationStatus?.(isResolved ? 'active' : 'resolved', parentCommentId) + }} + > + {isResolved ? 'Unresolve conversation' : 'Resolve conversation'} + </Button> + </div> </> )} </Card.Root> diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/sections/pull-request-changes-section.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/sections/pull-request-changes-section.tsx index e1cb1a6258..8ae8166280 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/sections/pull-request-changes-section.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/sections/pull-request-changes-section.tsx @@ -73,7 +73,7 @@ const AvatarItem: React.FC<AvatarItemProps> = ({ evaluations }: AvatarItemProps) return ( <Avatar key={owner?.id} className="size-6 rounded-full"> <AvatarFallback> - <span className="text-12 text-foreground-4"> + <span className="text-12 text-foreground-3"> {owner?.display_name && getInitials(owner?.display_name)} </span> </AvatarFallback> From d9c8a5e9084f1c4db18f97e61b28ee2aafcacff8 Mon Sep 17 00:00:00 2001 From: Calvin Lee <calvin.lee@harness.io> Date: Mon, 27 Jan 2025 14:53:12 -0700 Subject: [PATCH 07/16] feat: [pipe-24076]: fix closed issue on filter (#844) Signed-off-by: Calvin Lee <cjlee@ualberta.ca> Co-authored-by: Calvin Lee <cjlee@ualberta.ca> --- .../ui/src/views/repo/pull-request/pull-request-list-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx b/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx index 736d3065a7..a9cfe732ac 100644 --- a/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx +++ b/packages/ui/src/views/repo/pull-request/pull-request-list-page.tsx @@ -68,7 +68,7 @@ const PullRequestList: FC<PullRequestPageProps> = ({ const noData = !(sortedPullReqs && sortedPullReqs.length > 0) const handleCloseClick = () => { - filterHandlers.setActiveFilters([{ type: 'type', condition: 'is', selectedValues: ['disabled'] }]) + filterHandlers.handleResetFilters() } const handleOpenClick = () => { From bb4ca36b04c3060a8b58cf291e7c3926bc511212 Mon Sep 17 00:00:00 2001 From: vivek-harness <c_vivek.patel@harness.io> Date: Tue, 28 Jan 2025 11:18:39 +0530 Subject: [PATCH 08/16] Fixed breadcrumb failing API issue for MFE (#841) * Fixed breadcrumb failing API issue for MFE * Removed empty line --- apps/gitness/src/AppMFE.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/gitness/src/AppMFE.tsx b/apps/gitness/src/AppMFE.tsx index e0765ff783..3fcecfd92b 100644 --- a/apps/gitness/src/AppMFE.tsx +++ b/apps/gitness/src/AppMFE.tsx @@ -29,7 +29,7 @@ export interface MFERouteRendererProps { const filteredRoutes = extractRedirectRouteObjects(repoRoutes) const isRouteMatchingRedirectRoutes = (pathToValidate: string) => { - return filteredRoutes.every(route => !matchPath(`/${route.path}` as string, pathToValidate)) + return filteredRoutes.some(route => matchPath(`/${route.path}` as string, pathToValidate)) } function MFERouteRenderer({ renderUrl, parentLocationPath, onRouteChange }: MFERouteRendererProps) { @@ -50,7 +50,6 @@ function MFERouteRenderer({ renderUrl, parentLocationPath, onRouteChange }: MFER ) // Handle location change detected from parent route - useEffect(() => { if (canNavigate) { const pathToNavigate = parentLocationPath.replace(renderUrl, '') From e75b7e53bc6393b6de8fb9e40f5d5cf134dd5336 Mon Sep 17 00:00:00 2001 From: praneshg239 <95267551+praneshg239@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:46:25 +0530 Subject: [PATCH 09/16] Fix create repo API call plus portal and PR svg icons fill issue (#847) --- .../src/pages-v2/repo/repo-create-page.tsx | 4 +- packages/ui/src/components/select.tsx | 5 ++- packages/ui/src/components/sheet.tsx | 42 ++++++++++--------- packages/ui/src/icons/pr-closed.svg | 2 +- packages/ui/src/icons/pr-comment.svg | 2 +- packages/ui/src/icons/pr-merge.svg | 2 +- packages/ui/src/icons/pr-open.svg | 2 +- packages/ui/src/icons/pr-review.svg | 2 +- 8 files changed, 35 insertions(+), 26 deletions(-) diff --git a/apps/gitness/src/pages-v2/repo/repo-create-page.tsx b/apps/gitness/src/pages-v2/repo/repo-create-page.tsx index 48c1a766ae..bc187674e9 100644 --- a/apps/gitness/src/pages-v2/repo/repo-create-page.tsx +++ b/apps/gitness/src/pages-v2/repo/repo-create-page.tsx @@ -35,7 +35,9 @@ export const CreateRepo = () => { createRepositoryMutation.mutate( { - queryParams: {}, + queryParams: { + space_path: spaceURL + }, body: repositoryRequest }, { diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 99baab42d8..ec9bc651b7 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,6 +1,7 @@ import { Children, ComponentPropsWithoutRef, ElementRef, FC, forwardRef, PropsWithChildren, ReactNode } from 'react' import { Caption, Label, Message, MessageTheme, SearchBox } from '@/components' +import { usePortal } from '@/context' import { useDebounceSearch } from '@hooks/use-debounce-search' import { CheckIcon, ChevronDownIcon } from '@radix-ui/react-icons' import * as SelectPrimitive from '@radix-ui/react-select' @@ -114,8 +115,10 @@ const SelectContent = forwardRef< searchValue: searchProps?.searchValue }) + const { portalContainer } = usePortal() + return ( - <SelectPrimitive.Portal> + <SelectPrimitive.Portal container={portalContainer}> <SelectPrimitive.Content ref={ref} className={cn( diff --git a/packages/ui/src/components/sheet.tsx b/packages/ui/src/components/sheet.tsx index fb9bc4c523..8deafa4059 100644 --- a/packages/ui/src/components/sheet.tsx +++ b/packages/ui/src/components/sheet.tsx @@ -1,5 +1,6 @@ import * as React from 'react' +import { usePortal } from '@/context' import * as SheetPrimitive from '@radix-ui/react-dialog' import { cn } from '@utils/cn' import { cva, type VariantProps } from 'class-variance-authority' @@ -85,25 +86,28 @@ const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Con ...props }, ref - ) => ( - <SheetPortal> - <SheetOverlay modal={modal} className={overlayClassName} handleClose={handleClose || props.onClick} /> - <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> - {children} - {!hideCloseButton && ( - <SheetPrimitive.Close - asChild - className="absolute right-[0.1875rem] top-2 flex items-center justify-center transition-colors disabled:pointer-events-none" - > - <Button className="text-icons-4 hover:text-icons-2" variant="custom" size="icon" onClick={handleClose}> - <Icon name="close" size={16} /> - <span className="sr-only">Close</span> - </Button> - </SheetPrimitive.Close> - )} - </SheetPrimitive.Content> - </SheetPortal> - ) + ) => { + const { portalContainer } = usePortal() + return ( + <SheetPortal container={portalContainer}> + <SheetOverlay modal={modal} className={overlayClassName} handleClose={handleClose || props.onClick} /> + <SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}> + {children} + {!hideCloseButton && ( + <SheetPrimitive.Close + asChild + className="absolute right-[0.1875rem] top-2 flex items-center justify-center transition-colors disabled:pointer-events-none" + > + <Button className="text-icons-4 hover:text-icons-2" variant="custom" size="icon" onClick={handleClose}> + <Icon name="close" size={16} /> + <span className="sr-only">Close</span> + </Button> + </SheetPrimitive.Close> + )} + </SheetPrimitive.Content> + </SheetPortal> + ) + } ) SheetContent.displayName = SheetPrimitive.Content.displayName diff --git a/packages/ui/src/icons/pr-closed.svg b/packages/ui/src/icons/pr-closed.svg index 146760b15e..bfc6338882 100644 --- a/packages/ui/src/icons/pr-closed.svg +++ b/packages/ui/src/icons/pr-closed.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><circle cx="2.57" cy="2.57" r="1.714" stroke="currentColor" stroke-linecap="round"/><circle cx="9.427" cy="9.425" r="1.714" stroke="currentColor" stroke-linecap="round"/><path stroke="currentColor" stroke-linecap="round" d="M9.428 5.14v2.358"/><path fill="currentColor" fill-rule="evenodd" d="M7.824 1.725a.5.5 0 1 1 .707-.707l.895.894.895-.894a.5.5 0 1 1 .707.707l-.895.894.895.895a.5.5 0 1 1-.707.707l-.895-.895-.895.895a.5.5 0 1 1-.707-.707l.895-.895z" clip-rule="evenodd"/><circle cx="2.57" cy="9.425" r="1.714" stroke="currentColor" stroke-linecap="round"/><path stroke="currentColor" stroke-linecap="round" d="M2.57 4.285v3.429"/></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 12"><circle cx="2.57" cy="2.57" r="1.714" stroke="currentColor" stroke-linecap="round"/><circle cx="9.427" cy="9.425" r="1.714" stroke="currentColor" stroke-linecap="round"/><path stroke="currentColor" stroke-linecap="round" d="M9.428 5.14v2.358"/><path fill="currentColor" fill-rule="evenodd" d="M7.824 1.725a.5.5 0 1 1 .707-.707l.895.894.895-.894a.5.5 0 1 1 .707.707l-.895.894.895.895a.5.5 0 1 1-.707.707l-.895-.895-.895.895a.5.5 0 1 1-.707-.707l.895-.895z" clip-rule="evenodd"/><circle cx="2.57" cy="9.425" r="1.714" stroke="currentColor" stroke-linecap="round"/><path stroke="currentColor" stroke-linecap="round" d="M2.57 4.285v3.429"/></svg> \ No newline at end of file diff --git a/packages/ui/src/icons/pr-comment.svg b/packages/ui/src/icons/pr-comment.svg index 99cf4c7e09..9f26f8c699 100644 --- a/packages/ui/src/icons/pr-comment.svg +++ b/packages/ui/src/icons/pr-comment.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><g clip-path="url(#a)"><g clip-path="url(#b)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M.5 2v5A1.5 1.5 0 0 0 2 8.5h2l2 3 2-3h2A1.5 1.5 0 0 0 11.5 7V2A1.5 1.5 0 0 0 10 .5H2A1.5 1.5 0 0 0 .5 2"/></g></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath><clipPath id="b"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><g clip-path="url(#b)"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M.5 2v5A1.5 1.5 0 0 0 2 8.5h2l2 3 2-3h2A1.5 1.5 0 0 0 11.5 7V2A1.5 1.5 0 0 0 10 .5H2A1.5 1.5 0 0 0 .5 2"/></g></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath><clipPath id="b"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui/src/icons/pr-merge.svg b/packages/ui/src/icons/pr-merge.svg index 14da165a0d..8b718a0863 100644 --- a/packages/ui/src/icons/pr-merge.svg +++ b/packages/ui/src/icons/pr-merge.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><g stroke="currentColor" stroke-linecap="round" clip-path="url(#a)"><circle cx="2.57" cy="2.57" r="1.714"/><circle cx="9.427" cy="6.425" r="1.714"/><circle cx="2.57" cy="9.425" r="1.714"/><path d="M2.57 4.285v3.429M2.57 4.285C4.285 6 5.57 6.428 7.713 6.428"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 12"><g stroke="currentColor" stroke-linecap="round" clip-path="url(#a)"><circle cx="2.57" cy="2.57" r="1.714"/><circle cx="9.427" cy="6.425" r="1.714"/><circle cx="2.57" cy="9.425" r="1.714"/><path d="M2.57 4.285v3.429M2.57 4.285C4.285 6 5.57 6.428 7.713 6.428"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui/src/icons/pr-open.svg b/packages/ui/src/icons/pr-open.svg index 31f0b9292b..2b0b09581b 100644 --- a/packages/ui/src/icons/pr-open.svg +++ b/packages/ui/src/icons/pr-open.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><g stroke="currentColor" stroke-linecap="round" clip-path="url(#a)"><ellipse cx="2.622" cy="2.621" rx="1.622" ry="1.621"/><ellipse cx="9.378" cy="9.379" rx="1.622" ry="1.621"/><ellipse cx="2.622" cy="9.379" rx="1.622" ry="1.621"/><path d="M2.62 4.309v3.377M9.378 7.688V3.466a.845.845 0 0 0-.845-.845H6"/><path stroke-linejoin="round" d="M7.266 1.355 6 2.622 7.266 3.89"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 12"><g stroke="currentColor" stroke-linecap="round" clip-path="url(#a)"><ellipse cx="2.622" cy="2.621" rx="1.622" ry="1.621"/><ellipse cx="9.378" cy="9.379" rx="1.622" ry="1.621"/><ellipse cx="2.622" cy="9.379" rx="1.622" ry="1.621"/><path d="M2.62 4.309v3.377M9.378 7.688V3.466a.845.845 0 0 0-.845-.845H6"/><path stroke-linejoin="round" d="M7.266 1.355 6 2.622 7.266 3.89"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file diff --git a/packages/ui/src/icons/pr-review.svg b/packages/ui/src/icons/pr-review.svg index 38b4f5d581..94e390a622 100644 --- a/packages/ui/src/icons/pr-review.svg +++ b/packages/ui/src/icons/pr-review.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12"><g clip-path="url(#a)"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" clip-path="url(#b)"><path d="M.375 6S2.625 1.875 6 1.875 11.625 6 11.625 6 9.375 10.125 6 10.125.375 6 .375 6"/><path d="M6 7.875a1.875 1.875 0 1 0 0-3.75 1.875 1.875 0 0 0 0 3.75"/></g></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath><clipPath id="b"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 12 12"><g clip-path="url(#a)"><g stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" clip-path="url(#b)"><path d="M.375 6S2.625 1.875 6 1.875 11.625 6 11.625 6 9.375 10.125 6 10.125.375 6 .375 6"/><path d="M6 7.875a1.875 1.875 0 1 0 0-3.75 1.875 1.875 0 0 0 0 3.75"/></g></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath><clipPath id="b"><path fill="#fff" d="M0 0h12v12H0z"/></clipPath></defs></svg> \ No newline at end of file From d1b64016f3019aa1361f9a6dd614df20769d5f95 Mon Sep 17 00:00:00 2001 From: vivek-harness <c_vivek.patel@harness.io> Date: Tue, 28 Jan 2025 13:17:45 +0530 Subject: [PATCH 10/16] Breadcrumb api call fix (#848) * Fixed failing api call for repos in MFE * Changed retry limit to 5 instead of the default 3 * Renamed function * Changed variable name * Changed location to location.pathname in dependency array --- apps/gitness/src/AppMFE.tsx | 15 +++++++-------- apps/gitness/src/pages-v2/repo/repo-list.tsx | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/gitness/src/AppMFE.tsx b/apps/gitness/src/AppMFE.tsx index 3fcecfd92b..b72f85e50a 100644 --- a/apps/gitness/src/AppMFE.tsx +++ b/apps/gitness/src/AppMFE.tsx @@ -27,16 +27,16 @@ export interface MFERouteRendererProps { onRouteChange: (updatedLocationPathname: string) => void } -const filteredRoutes = extractRedirectRouteObjects(repoRoutes) -const isRouteMatchingRedirectRoutes = (pathToValidate: string) => { - return filteredRoutes.some(route => matchPath(`/${route.path}` as string, pathToValidate)) +const filteredRedirectRoutes = extractRedirectRouteObjects(repoRoutes) +const isRouteNotMatchingRedirectRoutes = (pathToValidate: string) => { + return filteredRedirectRoutes.every(route => !matchPath(`/${route.path}` as string, pathToValidate)) } function MFERouteRenderer({ renderUrl, parentLocationPath, onRouteChange }: MFERouteRendererProps) { const navigate = useNavigate() const location = useLocation() const parentPath = parentLocationPath.replace(renderUrl, '') - const isNotRedirectPath = isRouteMatchingRedirectRoutes(location.pathname) + const isNotRedirectPath = isRouteNotMatchingRedirectRoutes(location.pathname) /** * renderUrl ==> base URL of parent application @@ -52,17 +52,16 @@ function MFERouteRenderer({ renderUrl, parentLocationPath, onRouteChange }: MFER // Handle location change detected from parent route useEffect(() => { if (canNavigate) { - const pathToNavigate = parentLocationPath.replace(renderUrl, '') - navigate(pathToNavigate, { replace: true }) + navigate(parentPath, { replace: true }) } - }, [parentLocationPath]) + }, [parentPath]) // Notify parent about route change useEffect(() => { if (canNavigate) { onRouteChange?.(`${renderUrl}${location.pathname}`) } - }, [location]) + }, [location.pathname]) return null } diff --git a/apps/gitness/src/pages-v2/repo/repo-list.tsx b/apps/gitness/src/pages-v2/repo/repo-list.tsx index 8927208d19..a94ff3e162 100644 --- a/apps/gitness/src/pages-v2/repo/repo-list.tsx +++ b/apps/gitness/src/pages-v2/repo/repo-list.tsx @@ -39,7 +39,7 @@ export default function ReposListPage() { space_ref: `${spaceURL}/+` }, { - retry: false + retry: 5 } ) From 88753baff359e1e061e1edd52ac26b5cd768fc0e Mon Sep 17 00:00:00 2001 From: vivek-harness <c_vivek.patel@harness.io> Date: Tue, 28 Jan 2025 18:13:50 +0530 Subject: [PATCH 11/16] Mfe bug fixes (#850) * Fixed deleted comment black background issue in mfe light mode * Fixed current user not available in AppContext for MFE * Fixed: When adding a PR comment 0 is displayed till the API call completes --- .../src/framework/context/AppContext.tsx | 15 +- packages/ui/src/shared-style-variables.css | 2 +- .../conversation/pull-request-overview.tsx | 407 +++++++++--------- 3 files changed, 208 insertions(+), 216 deletions(-) diff --git a/apps/gitness/src/framework/context/AppContext.tsx b/apps/gitness/src/framework/context/AppContext.tsx index 966b1fbb88..62a1b13625 100644 --- a/apps/gitness/src/framework/context/AppContext.tsx +++ b/apps/gitness/src/framework/context/AppContext.tsx @@ -27,15 +27,22 @@ export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => const [currentUser, setCurrentUser] = useLocalStorage<TypesUser>('currentUser', {}) useEffect(() => { - Promise.all([ + Promise.allSettled([ membershipSpaces({ queryParams: { page: 1, limit: 100, sort: 'identifier', order: 'asc' } }), getUser({}) ]) - .then(([membershipResponse, userResponse]) => { - setSpaces(membershipResponse.body.filter(item => item?.space).map(item => item.space as TypesSpace)) - setCurrentUser(userResponse.body) + .then(results => { + const [membershipResult, userResult] = results + + if (membershipResult.status === 'fulfilled') { + setSpaces(membershipResult.value.body.filter(item => item?.space).map(item => item.space as TypesSpace)) + } + + if (userResult.status === 'fulfilled') { + setCurrentUser(userResult.value.body) + } }) .catch(() => { // Optionally handle error or show toast diff --git a/packages/ui/src/shared-style-variables.css b/packages/ui/src/shared-style-variables.css index 0244d53dce..392a637118 100644 --- a/packages/ui/src/shared-style-variables.css +++ b/packages/ui/src/shared-style-variables.css @@ -287,7 +287,7 @@ --canary-popover: 0 0% 100%; --canary-popover-foreground: 240 10% 3.9%; --canary-primary: 240 5.9% 10%; - --canary-primary-background: 240 5.9% 10%; + --canary-primary-background: 0 0% 100%; --canary-primary-foreground: 0 0% 98%; --canary-primary-muted: var(--canary-grey-70); --canary-primary-accent: 216 100% 60%; diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx index 4bc0f498b0..7d07e2a673 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-overview.tsx @@ -236,192 +236,25 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ ? parseStartingLineIfOne(codeDiffSnapshot ?? '') : null - return ( - payload?.id && ( - <PullRequestTimelineItem - handleUpload={handleUpload} - data={payload?.text as string} - isNotCodeComment - id={parentIdAttr} - hideReplyHere={hideReplyHeres[payload?.id]} - setHideReplyHere={state => toggleReplyBox(state, payload?.id)} - quoteReplyText={quoteReplies[payload.id]?.text || ''} - onQuoteReply={handleQuoteReply} - key={payload?.id} - currentUser={currentUser?.display_name} - replyBoxClassName="p-4" - toggleConversationStatus={toggleConversationStatus} - isResolved={!!payload?.resolved} - icon={<Icon name="pr-review" size={12} />} - isLast={(data && data?.length - 1 === index) ?? false} - handleSaveComment={handleSaveComment} - parentCommentId={payload?.id} - header={[ - { - avatar: ( - <Avatar className="size-6 rounded-full p-0"> - <AvatarFallback> - <span className="text-12 text-foreground-3"> - {/* TODO: fix fallback string */} - {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} - </span> - </AvatarFallback> - </Avatar> - ), - name: (payload?.author as PayloadAuthor)?.display_name, - // TODO: fix comment to tell between comment or code comment? - description: payload?.created && `reviewed ${timeAgo(payload?.created)}` - } - ]} - contentHeader={<span>{(payload?.code_comment as PayloadCodeComment)?.path}</span>} - content={ - <div className="flex flex-col"> - {startingLine ? ( - <div className="bg-[--diff-hunk-lineNumber--]"> - <div className="ml-16 w-full px-8 py-1">{startingLine}</div> - </div> - ) : null} - - <PullRequestDiffViewer - handleUpload={handleUpload} - data={removeLastPlus(codeDiffSnapshot)} - fileName={payload?.code_comment?.path ?? ''} - lang={(payload?.code_comment?.path && payload?.code_comment?.path.split('.').pop()) || ''} - fontsize={fontsize} - highlight={highlight} - mode={DiffModeEnum.Unified} - wrap={wrap} - addWidget={false} - useTranslationStore={useTranslationStore} - /> - <div className="px-4 py-2"> - {commentItems?.map((commentItem, idx) => { - const componentId = `activity-code-${commentItem?.id}` - const commentIdAttr = `comment-${payload?.id}` - - return ( - payload?.id && ( - <PullRequestTimelineItem - handleUpload={handleUpload} - id={commentIdAttr} - data={commentItem.payload?.payload?.text as string} - isNotCodeComment - hideReplySection - setHideReplyHere={state => toggleReplyBox(state, payload?.id)} - quoteReplyText={quoteReplies[payload.id]?.text || ''} - onQuoteReply={handleQuoteReply} - parentCommentId={payload?.id} - titleClassName="!flex max-w-full" - isComment - onCopyClick={onCopyClick} - commentId={commentItem.id} - isLast={commentItems.length - 1 === idx} - isDeleted={!!commentItem?.deleted} - handleDeleteComment={() => handleDeleteComment(commentItem?.id)} - onEditClick={() => - toggleEditMode(componentId, commentItem?.payload?.payload?.text || '') - } - contentClassName="border-transparent" - replyBoxClassName="p-4" - toggleConversationStatus={toggleConversationStatus} - icon={ - <Avatar className="size-6 rounded-full p-0"> - <AvatarFallback> - <span className="text-12 text-foreground-3"> - {/* TODO: fix fallback string */} - {getInitials( - ( - (commentItem as unknown as TypesPullReqActivity)?.payload - ?.author as PayloadAuthor - )?.display_name || '' - )} - </span> - </AvatarFallback> - </Avatar> - } - header={[ - { - name: ( - (commentItem as unknown as TypesPullReqActivity)?.payload - ?.author as PayloadAuthor - )?.display_name, - // TODO: fix comment to tell between comment or code comment? - description: ( - <Layout.Horizontal className="text-foreground-4"> - <span>{timeAgo((commentItem as unknown as PayloadCreated)?.created)}</span> - {commentItem?.deleted ? ( - <> - <span> | </span> - <span>{t('views:pullRequests.deleted')}</span> - </> - ) : null} - </Layout.Horizontal> - ) - } - ]} - content={ - commentItem?.deleted ? ( - <div className="rounded-md border bg-primary-background p-1"> - {t('views:pullRequests.deletedComment')} - </div> - ) : editModes[componentId] ? ( - <PullRequestCommentBox - isEditMode - handleUpload={handleUpload} - onSaveComment={() => { - if (commentItem?.id) { - handleUpdateComment?.(commentItem?.id, editComments[componentId]) - toggleEditMode(componentId, '') - } - }} - currentUser={currentUser?.display_name} - onCancelClick={() => { - toggleEditMode(componentId, '') - }} - comment={editComments[componentId]} - setComment={text => - setEditComments(prev => ({ ...prev, [componentId]: text })) - } - /> - ) : ( - <PRCommentView - commentItem={commentItem} - filenameToLanguage={filenameToLanguage} - suggestionsBatch={suggestionsBatch} - onCommitSuggestion={onCommitSuggestion} - addSuggestionToBatch={addSuggestionToBatch} - removeSuggestionFromBatch={removeSuggestionFromBatch} - /> - ) - } - key={`${commentItem.id}-${commentItem.author}`} - /> - ) - ) - })} - </div> - </div> - } - /> - ) - ) - } - return ( - payload?.id && ( + return payload?.id ? ( <PullRequestTimelineItem handleUpload={handleUpload} data={payload?.text as string} + isNotCodeComment id={parentIdAttr} hideReplyHere={hideReplyHeres[payload?.id]} setHideReplyHere={state => toggleReplyBox(state, payload?.id)} quoteReplyText={quoteReplies[payload.id]?.text || ''} onQuoteReply={handleQuoteReply} key={payload?.id} - titleClassName="!flex max-w-full" currentUser={currentUser?.display_name} replyBoxClassName="p-4" - isResolved={!!payload?.resolved} toggleConversationStatus={toggleConversationStatus} + isResolved={!!payload?.resolved} + icon={<Icon name="pr-review" size={12} />} + isLast={(data && data?.length - 1 === index) ?? false} + handleSaveComment={handleSaveComment} + parentCommentId={payload?.id} header={[ { avatar: ( @@ -436,46 +269,59 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ ), name: (payload?.author as PayloadAuthor)?.display_name, // TODO: fix comment to tell between comment or code comment? - description: ( - <div className="flex space-x-4"> - <div className="pr-2">{payload?.created && `commented ${timeAgo(payload?.created)}`} </div> - </div> - ) + description: payload?.created && `reviewed ${timeAgo(payload?.created)}` } ]} + contentHeader={<span>{(payload?.code_comment as PayloadCodeComment)?.path}</span>} content={ - <div className="px-4 pt-4"> - {commentItems?.map((commentItem, idx) => { - const componentId = `activity-comment-${commentItem?.id}` - // const diffCommentItem = activitiesToDiffCommentItems(commentItem) - const commentIdAttr = `comment-${payload?.id}` + <div className="flex flex-col"> + {startingLine ? ( + <div className="bg-[--diff-hunk-lineNumber--]"> + <div className="ml-16 w-full px-8 py-1">{startingLine}</div> + </div> + ) : null} + + <PullRequestDiffViewer + handleUpload={handleUpload} + data={removeLastPlus(codeDiffSnapshot)} + fileName={payload?.code_comment?.path ?? ''} + lang={(payload?.code_comment?.path && payload?.code_comment?.path.split('.').pop()) || ''} + fontsize={fontsize} + highlight={highlight} + mode={DiffModeEnum.Unified} + wrap={wrap} + addWidget={false} + useTranslationStore={useTranslationStore} + /> + <div className="px-4 py-2"> + {commentItems?.map((commentItem, idx) => { + const componentId = `activity-code-${commentItem?.id}` + const commentIdAttr = `comment-${payload?.id}` - return ( - payload?.id && ( + return payload?.id ? ( <PullRequestTimelineItem handleUpload={handleUpload} id={commentIdAttr} data={commentItem.payload?.payload?.text as string} + isNotCodeComment hideReplySection setHideReplyHere={state => toggleReplyBox(state, payload?.id)} quoteReplyText={quoteReplies[payload.id]?.text || ''} onQuoteReply={handleQuoteReply} parentCommentId={payload?.id} - toggleConversationStatus={toggleConversationStatus} titleClassName="!flex max-w-full" - currentUser={currentUser?.display_name} - isLast={commentItems.length - 1 === idx} isComment onCopyClick={onCopyClick} commentId={commentItem.id} + isLast={commentItems.length - 1 === idx} isDeleted={!!commentItem?.deleted} handleDeleteComment={() => handleDeleteComment(commentItem?.id)} onEditClick={() => toggleEditMode(componentId, commentItem?.payload?.payload?.text || '') } - contentClassName="border-transparent pb-0" + contentClassName="border-transparent" replyBoxClassName="p-4" - key={`${commentItem.id}-${commentItem.author}-pr-comment`} + toggleConversationStatus={toggleConversationStatus} icon={ <Avatar className="size-6 rounded-full p-0"> <AvatarFallback> @@ -485,7 +331,7 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ ( (commentItem as unknown as TypesPullReqActivity)?.payload ?.author as PayloadAuthor - ).display_name || '' + )?.display_name || '' )} </span> </AvatarFallback> @@ -498,16 +344,12 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ )?.display_name, // TODO: fix comment to tell between comment or code comment? description: ( - <Layout.Horizontal> - <span className="text-foreground-3"> - {timeAgo((commentItem as unknown as PayloadCreated)?.created)} - </span> + <Layout.Horizontal className="text-foreground-4"> + <span>{timeAgo((commentItem as unknown as PayloadCreated)?.created)}</span> {commentItem?.deleted ? ( <> - <span className="text-foreground-3"> | </span> - <span className="text-foreground-3"> - {t('views:pullRequests.deleted')}{' '} - </span> + <span> | </span> + <span>{t('views:pullRequests.deleted')}</span> </> ) : null} </Layout.Horizontal> @@ -521,8 +363,8 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ </div> ) : editModes[componentId] ? ( <PullRequestCommentBox - handleUpload={handleUpload} isEditMode + handleUpload={handleUpload} onSaveComment={() => { if (commentItem?.id) { handleUpdateComment?.(commentItem?.id, editComments[componentId]) @@ -547,20 +389,163 @@ const PullRequestOverview: React.FC<PullRequestOverviewProps> = ({ /> ) } + key={`${commentItem.id}-${commentItem.author}`} /> - ) - ) - })} + ) : null + })} + </div> </div> - // } - icon={<Icon name="pr-comment" size={12} />} - isLast={activityBlocks.length - 1 === index} - handleSaveComment={handleSaveComment} - parentCommentId={payload?.id} /> - ) - ) + ) : null + } + return payload?.id ? ( + <PullRequestTimelineItem + handleUpload={handleUpload} + data={payload?.text as string} + id={parentIdAttr} + hideReplyHere={hideReplyHeres[payload?.id]} + setHideReplyHere={state => toggleReplyBox(state, payload?.id)} + quoteReplyText={quoteReplies[payload.id]?.text || ''} + onQuoteReply={handleQuoteReply} + key={payload?.id} + titleClassName="!flex max-w-full" + currentUser={currentUser?.display_name} + replyBoxClassName="p-4" + isResolved={!!payload?.resolved} + toggleConversationStatus={toggleConversationStatus} + header={[ + { + avatar: ( + <Avatar className="size-6 rounded-full p-0"> + <AvatarFallback> + <span className="text-12 text-foreground-3"> + {/* TODO: fix fallback string */} + {getInitials((payload?.author as PayloadAuthor)?.display_name || '')} + </span> + </AvatarFallback> + </Avatar> + ), + name: (payload?.author as PayloadAuthor)?.display_name, + // TODO: fix comment to tell between comment or code comment? + description: ( + <div className="flex space-x-4"> + <div className="pr-2">{payload?.created && `commented ${timeAgo(payload?.created)}`} </div> + </div> + ) + } + ]} + content={ + <div className="px-4 pt-4"> + {commentItems?.map((commentItem, idx) => { + const componentId = `activity-comment-${commentItem?.id}` + // const diffCommentItem = activitiesToDiffCommentItems(commentItem) + const commentIdAttr = `comment-${payload?.id}` + + return payload?.id ? ( + <PullRequestTimelineItem + handleUpload={handleUpload} + id={commentIdAttr} + data={commentItem.payload?.payload?.text as string} + hideReplySection + setHideReplyHere={state => toggleReplyBox(state, payload?.id)} + quoteReplyText={quoteReplies[payload.id]?.text || ''} + onQuoteReply={handleQuoteReply} + parentCommentId={payload?.id} + toggleConversationStatus={toggleConversationStatus} + titleClassName="!flex max-w-full" + currentUser={currentUser?.display_name} + isLast={commentItems.length - 1 === idx} + isComment + onCopyClick={onCopyClick} + commentId={commentItem.id} + isDeleted={!!commentItem?.deleted} + handleDeleteComment={() => handleDeleteComment(commentItem?.id)} + onEditClick={() => toggleEditMode(componentId, commentItem?.payload?.payload?.text || '')} + contentClassName="border-transparent pb-0" + replyBoxClassName="p-4" + key={`${commentItem.id}-${commentItem.author}-pr-comment`} + icon={ + <Avatar className="size-6 rounded-full p-0"> + <AvatarFallback> + <span className="text-12 text-foreground-3"> + {/* TODO: fix fallback string */} + {getInitials( + ( + (commentItem as unknown as TypesPullReqActivity)?.payload + ?.author as PayloadAuthor + ).display_name || '' + )} + </span> + </AvatarFallback> + </Avatar> + } + header={[ + { + name: ( + (commentItem as unknown as TypesPullReqActivity)?.payload?.author as PayloadAuthor + )?.display_name, + // TODO: fix comment to tell between comment or code comment? + description: ( + <Layout.Horizontal> + <span className="text-foreground-3"> + {timeAgo((commentItem as unknown as PayloadCreated)?.created)} + </span> + {commentItem?.deleted ? ( + <> + <span className="text-foreground-3"> | </span> + <span className="text-foreground-3">{t('views:pullRequests.deleted')} </span> + </> + ) : null} + </Layout.Horizontal> + ) + } + ]} + content={ + commentItem?.deleted ? ( + <div className="rounded-md border bg-primary-background p-1"> + {t('views:pullRequests.deletedComment')} + </div> + ) : editModes[componentId] ? ( + <PullRequestCommentBox + handleUpload={handleUpload} + isEditMode + onSaveComment={() => { + if (commentItem?.id) { + handleUpdateComment?.(commentItem?.id, editComments[componentId]) + toggleEditMode(componentId, '') + } + }} + currentUser={currentUser?.display_name} + onCancelClick={() => { + toggleEditMode(componentId, '') + }} + comment={editComments[componentId]} + setComment={text => setEditComments(prev => ({ ...prev, [componentId]: text }))} + /> + ) : ( + <PRCommentView + commentItem={commentItem} + filenameToLanguage={filenameToLanguage} + suggestionsBatch={suggestionsBatch} + onCommitSuggestion={onCommitSuggestion} + addSuggestionToBatch={addSuggestionToBatch} + removeSuggestionFromBatch={removeSuggestionFromBatch} + /> + ) + } + /> + ) : null + })} + </div> + // + } + icon={<Icon name="pr-comment" size={12} />} + isLast={activityBlocks.length - 1 === index} + handleSaveComment={handleSaveComment} + parentCommentId={payload?.id} + /> + ) : null } })} </div> From 5c95b943d89992818536619cd88f62b75c7d1248 Mon Sep 17 00:00:00 2001 From: Kevin Nagurski <Kevin.nagurski@harness.io> Date: Tue, 28 Jan 2025 18:19:02 +0000 Subject: [PATCH 12/16] feat: Add initial `Sidebar` component (#852) * feat: add shadcn components file * feat: Add initial `Sidebar` component fixes #726 --- packages/ui/components.json | 21 + packages/ui/src/components/icon.tsx | 4 + packages/ui/src/components/index.ts | 1 + .../ui/src/components/sidebar/separator.tsx | 21 + .../ui/src/components/sidebar/sidebar.tsx | 639 ++++++++++++++++++ .../ui/src/components/sidebar/skeleton.tsx | 9 + .../src/components/sidebar/use-is-mobile.tsx | 19 + packages/ui/src/icons/sidebar-left.svg | 1 + packages/ui/src/icons/sidebar-right.svg | 1 + .../src/views/layouts/PullRequestLayout.tsx | 2 +- .../components/pull-request-header.tsx | 10 +- .../components/pull-request-item-title.tsx | 4 +- .../components/pull-request-list.tsx | 2 +- .../conversation/pull-request-comment-box.tsx | 4 +- .../repo/repo-summary/repo-empty-view.tsx | 4 +- .../components/repo-webhook-list.tsx | 2 +- 16 files changed, 730 insertions(+), 14 deletions(-) create mode 100644 packages/ui/components.json create mode 100644 packages/ui/src/components/sidebar/separator.tsx create mode 100644 packages/ui/src/components/sidebar/sidebar.tsx create mode 100644 packages/ui/src/components/sidebar/skeleton.tsx create mode 100644 packages/ui/src/components/sidebar/use-is-mobile.tsx create mode 100644 packages/ui/src/icons/sidebar-left.svg create mode 100644 packages/ui/src/icons/sidebar-right.svg diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 0000000000..60c27621ba --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.ts", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/utils", + "ui": "@/components", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index be10506d03..908e75be39 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -154,6 +154,8 @@ import Settings2 from '../icons/setting-2.svg' import Shield from '../icons/shield-icon.svg' import ShieldLock from '../icons/shield-lock.svg' import SidebarIcon from '../icons/sidebar-icon.svg' +import SidebarLeft from '../icons/sidebar-left.svg' +import SidebarRight from '../icons/sidebar-right.svg' import Signpost from '../icons/signpost.svg' import Snow from '../icons/snow-icon.svg' import Sparks from '../icons/sparks.svg' @@ -295,6 +297,8 @@ const IconNameMap = { key: Key, 'file-icon': FileIcon, 'sidebar-icon': SidebarIcon, + 'sidebar-left': SidebarLeft, + 'sidebar-right': SidebarRight, variable: Variable, 'clock-icon': ClockIcon, eye: Eye, diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 08475baa42..518e745bf9 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -68,6 +68,7 @@ export * from './multi-select' export * from './button-with-options' export * from './tooltip' export * from './navigation-menu' +export * from './sidebar/sidebar' export * as NodeGroup from './node-group' export * as ShaBadge from './sha-badge' diff --git a/packages/ui/src/components/sidebar/separator.tsx b/packages/ui/src/components/sidebar/separator.tsx new file mode 100644 index 0000000000..bf5df08ce0 --- /dev/null +++ b/packages/ui/src/components/sidebar/separator.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react' + +import * as SeparatorPrimitive from '@radix-ui/react-separator' +import { cn } from '@utils/cn' + +const Separator = forwardRef< + ElementRef<typeof SeparatorPrimitive.Root>, + ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className)} + {...props} + /> +)) +Separator.displayName = SeparatorPrimitive.Root.displayName + +export { Separator } diff --git a/packages/ui/src/components/sidebar/sidebar.tsx b/packages/ui/src/components/sidebar/sidebar.tsx new file mode 100644 index 0000000000..1cee9287ce --- /dev/null +++ b/packages/ui/src/components/sidebar/sidebar.tsx @@ -0,0 +1,639 @@ +import { + ComponentProps, + ComponentPropsWithoutRef, + createContext, + CSSProperties, + ElementRef, + forwardRef, + useCallback, + useContext, + useEffect, + useMemo, + useState +} from 'react' + +import { Button, Icon, Input, Sheet, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components' +import { Slot } from '@radix-ui/react-slot' +import { cn } from '@utils/cn' +import { cva, VariantProps } from 'class-variance-authority' + +import { Separator } from './separator' +import { Skeleton } from './skeleton' +import { useIsMobile } from './use-is-mobile' + +const SIDEBAR_COOKIE_NAME = 'sidebar:state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' + +type SidebarContext = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = createContext<SidebarContext | null>(null) + +function useSidebar() { + const context = useContext(SidebarContext) + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +const SidebarProvider = forwardRef< + HTMLDivElement, + ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>(({ defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props }, ref) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = useState(defaultOpen) + const open = openProp ?? _open + const setOpen = useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = useCallback(() => { + return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = useMemo<SidebarContext>( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + <SidebarContext.Provider value={contextValue}> + <TooltipProvider delayDuration={0}> + <div + style={ + { + '--sidebar-width': SIDEBAR_WIDTH, + '--sidebar-width-icon': SIDEBAR_WIDTH_ICON, + ...style + } as CSSProperties + } + className={cn('group/sidebar-wrapper flex min-h-svh w-full has-[[data-variant=inset]]:bg-sidebar', className)} + ref={ref} + {...props} + > + {children} + </div> + </TooltipProvider> + </SidebarContext.Provider> + ) +}) +SidebarProvider.displayName = 'SidebarProvider' + +const SidebarRoot = forwardRef< + HTMLDivElement, + ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' + } +>(({ side = 'left', variant = 'sidebar', collapsible = 'offcanvas', className, children, ...props }, ref) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( + <div + className={cn('flex h-full w-[--sidebar-width] flex-col bg-sidebar text-sidebar-foreground', className)} + ref={ref} + {...props} + > + {children} + </div> + ) + } + + if (isMobile) { + return ( + <Sheet.Root open={openMobile} onOpenChange={setOpenMobile} {...props}> + <Sheet.Content + data-sidebar="sidebar" + data-mobile="true" + className="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden" + style={ + { + '--sidebar-width': SIDEBAR_WIDTH_MOBILE + } as CSSProperties + } + side={side} + > + <div className="flex size-full flex-col">{children}</div> + </Sheet.Content> + </Sheet.Root> + ) + } + + return ( + <div + ref={ref} + className="text-sidebar-foreground group peer hidden md:block" + data-state={state} + data-collapsible={state === 'collapsed' ? collapsible : ''} + data-variant={variant} + data-side={side} + > + {/* This is what handles the sidebar gap on desktop */} + <div + className={cn( + 'duration-200 relative h-svh w-[--sidebar-width] bg-transparent transition-[width] ease-linear', + 'group-data-[collapsible=offcanvas]:w-0', + 'group-data-[side=right]:rotate-180', + variant === 'floating' || variant === 'inset' + ? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]' + : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon]' + )} + /> + <div + className={cn( + 'duration-200 fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] ease-linear md:flex', + side === 'left' + ? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]' + : 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]', + // Adjust the padding for floating and inset variants. + variant === 'floating' || variant === 'inset' + ? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]' + : 'group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l', + className + )} + {...props} + > + <div + data-sidebar="sidebar" + className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow" + > + {children} + </div> + </div> + </div> + ) +}) +SidebarRoot.displayName = 'SidebarRoot' + +const SidebarTrigger = forwardRef<ElementRef<typeof Button>, ComponentProps<typeof Button>>( + ({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <Button + ref={ref} + data-sidebar="trigger" + variant="ghost" + size="icon" + className={cn('h-7 w-7', className)} + onClick={event => { + onClick?.(event) + toggleSidebar() + }} + {...props} + > + <Icon name="sidebar-left" /> + <span className="sr-only">Toggle Sidebar</span> + </Button> + ) + } +) +SidebarTrigger.displayName = 'SidebarTrigger' + +const SidebarRail = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + <button + ref={ref} + data-sidebar="rail" + aria-label="Toggle Sidebar" + tabIndex={-1} + onClick={toggleSidebar} + title="Toggle Sidebar" + className={cn( + 'absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex', + '[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize', + '[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize', + 'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar', + '[[data-side=left][data-collapsible=offcanvas]_&]:-right-2', + '[[data-side=right][data-collapsible=offcanvas]_&]:-left-2', + className + )} + {...props} + /> + ) +}) +SidebarRail.displayName = 'SidebarRail' + +const SidebarInset = forwardRef<HTMLDivElement, ComponentProps<'main'>>(({ className, ...props }, ref) => { + return ( + <main + ref={ref} + className={cn( + 'relative flex min-h-svh flex-1 flex-col bg-background', + 'peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow', + className + )} + {...props} + /> + ) +}) +SidebarInset.displayName = 'SidebarInset' + +const SidebarInput = forwardRef<ElementRef<typeof Input>, ComponentProps<typeof Input>>( + ({ className, ...props }, ref) => { + return ( + <Input + ref={ref} + data-sidebar="input" + className={cn( + 'h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring', + className + )} + {...props} + /> + ) + } +) +SidebarInput.displayName = 'SidebarInput' + +const SidebarHeader = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => { + return <div ref={ref} data-sidebar="header" className={cn('flex flex-col gap-2 px-2', className)} {...props} /> +}) +SidebarHeader.displayName = 'SidebarHeader' + +const SidebarFooter = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => { + return <div ref={ref} data-sidebar="footer" className={cn('flex flex-col gap-2 p-2', className)} {...props} /> +}) +SidebarFooter.displayName = 'SidebarFooter' + +const SidebarSeparator = forwardRef<ElementRef<typeof Separator>, ComponentProps<typeof Separator>>( + ({ className, ...props }, ref) => { + return ( + <Separator + ref={ref} + data-sidebar="separator" + className={cn('mx-2 w-auto bg-sidebar-border', className)} + {...props} + /> + ) + } +) +SidebarSeparator.displayName = 'SidebarSeparator' + +const SidebarContent = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="content" + className={cn( + 'flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden', + className + )} + {...props} + /> + ) +}) +SidebarContent.displayName = 'SidebarContent' + +const SidebarGroup = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => { + return ( + <div + ref={ref} + data-sidebar="group" + className={cn('relative flex w-full min-w-0 flex-col p-2', className)} + {...props} + /> + ) +}) +SidebarGroup.displayName = 'SidebarGroup' + +const SidebarGroupLabel = forwardRef<HTMLDivElement, ComponentPropsWithoutRef<'div'> & { asChild?: boolean }>( + ({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'div' + + return ( + <Comp + ref={ref} + data-sidebar="group-label" + className={cn( + 'duration-200 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 outline-none ring-sidebar-ring transition-[margin,opa] ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + 'group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0', + className + )} + {...props} + /> + ) + } +) +SidebarGroupLabel.displayName = 'SidebarGroupLabel' + +const SidebarGroupAction = forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<'button'> & { asChild?: boolean }>( + ({ className, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + + return ( + <Comp + ref={ref} + data-sidebar="group-action" + className={cn( + 'absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0', + // Increases the hit area of the button on mobile. + 'after:absolute after:-inset-2 after:md:hidden', + 'group-data-[collapsible=icon]:hidden', + className + )} + {...props} + /> + ) + } +) +SidebarGroupAction.displayName = 'SidebarGroupAction' + +const SidebarGroupContent = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => ( + <div ref={ref} data-sidebar="group-content" className={cn('w-full text-sm', className)} {...props} /> +)) +SidebarGroupContent.displayName = 'SidebarGroupContent' + +const SidebarMenu = forwardRef<HTMLUListElement, ComponentProps<'ul'>>(({ className, ...props }, ref) => ( + <ul ref={ref} data-sidebar="menu" className={cn('flex w-full min-w-0 flex-col gap-1', className)} {...props} /> +)) +SidebarMenu.displayName = 'SidebarMenu' + +const SidebarMenuItem = forwardRef<HTMLLIElement, ComponentProps<'li'>>(({ className, ...props }, ref) => ( + <li ref={ref} data-sidebar="menu-item" className={cn('group/menu-item relative', className)} {...props} /> +)) +SidebarMenuItem.displayName = 'SidebarMenuItem' + +const sidebarMenuButtonVariants = cva( + 'peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + { + variants: { + variant: { + default: 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground', + outline: + 'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]' + }, + size: { + default: 'h-8 text-sm', + sm: 'h-7 text-xs', + lg: 'h-12 text-sm group-data-[collapsible=icon]:!p-0' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } + } +) + +const SidebarMenuButton = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<'button'> & { + asChild?: boolean + isActive?: boolean + tooltip?: string | ComponentProps<typeof TooltipContent> + } & VariantProps<typeof sidebarMenuButtonVariants> +>(({ asChild = false, isActive = false, variant = 'default', size = 'default', tooltip, className, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + const { isMobile, state } = useSidebar() + + const button = ( + <Comp + ref={ref} + data-sidebar="menu-button" + data-size={size} + data-active={isActive} + className={cn(sidebarMenuButtonVariants({ variant, size }), className)} + {...props} + /> + ) + + if (!tooltip) { + return button + } + + if (typeof tooltip === 'string') { + tooltip = { + children: tooltip + } + } + + return ( + <Tooltip> + <TooltipTrigger asChild>{button}</TooltipTrigger> + <TooltipContent side="right" align="center" hidden={state !== 'collapsed' || isMobile} {...tooltip} /> + </Tooltip> + ) +}) +SidebarMenuButton.displayName = 'SidebarMenuButton' + +const SidebarMenuAction = forwardRef< + HTMLButtonElement, + ComponentPropsWithoutRef<'button'> & { + asChild?: boolean + showOnHover?: boolean + } +>(({ className, asChild = false, showOnHover = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + + return ( + <Comp + ref={ref} + data-sidebar="menu-action" + className={cn( + 'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0', + // Increases the hit area of the button on mobile. + 'after:absolute after:-inset-2 after:md:hidden', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + showOnHover && + 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', + className + )} + {...props} + /> + ) +}) +SidebarMenuAction.displayName = 'SidebarMenuAction' + +const SidebarMenuBadge = forwardRef<HTMLDivElement, ComponentProps<'div'>>(({ className, ...props }, ref) => ( + <div + ref={ref} + data-sidebar="menu-badge" + className={cn( + 'absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums text-sidebar-foreground select-none pointer-events-none', + 'peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground', + 'peer-data-[size=sm]/menu-button:top-1', + 'peer-data-[size=default]/menu-button:top-1.5', + 'peer-data-[size=lg]/menu-button:top-2.5', + 'group-data-[collapsible=icon]:hidden', + className + )} + {...props} + /> +)) +SidebarMenuBadge.displayName = 'SidebarMenuBadge' + +const SidebarMenuSkeleton = forwardRef< + HTMLDivElement, + ComponentProps<'div'> & { + showIcon?: boolean + } +>(({ className, showIcon = false, ...props }, ref) => { + // Random width between 50 to 90%. + const width = useMemo(() => { + return `${Math.floor(Math.random() * 40) + 50}%` + }, []) + + return ( + <div + ref={ref} + data-sidebar="menu-skeleton" + className={cn('rounded-md h-8 flex gap-2 px-2 items-center', className)} + {...props} + > + {showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />} + <Skeleton + className="h-4 max-w-[--skeleton-width] flex-1" + data-sidebar="menu-skeleton-text" + style={ + { + '--skeleton-width': width + } as CSSProperties + } + /> + </div> + ) +}) +SidebarMenuSkeleton.displayName = 'SidebarMenuSkeleton' + +const SidebarMenuSub = forwardRef<HTMLUListElement, ComponentProps<'ul'>>(({ className, ...props }, ref) => ( + <ul + ref={ref} + data-sidebar="menu-sub" + className={cn( + 'mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5', + 'group-data-[collapsible=icon]:hidden', + className + )} + {...props} + /> +)) +SidebarMenuSub.displayName = 'SidebarMenuSub' + +const SidebarMenuSubItem = forwardRef<HTMLLIElement, ComponentProps<'li'>>(({ ...props }, ref) => ( + <li ref={ref} {...props} /> +)) +SidebarMenuSubItem.displayName = 'SidebarMenuSubItem' + +const SidebarMenuSubButton = forwardRef< + HTMLAnchorElement, + ComponentPropsWithoutRef<'a'> & { + asChild?: boolean + size?: 'sm' | 'md' + isActive?: boolean + } +>(({ asChild = false, size = 'md', isActive, className, ...props }, ref) => { + const Comp = asChild ? Slot : 'a' + + return ( + <Comp + ref={ref} + data-sidebar="menu-sub-button" + data-size={size} + data-active={isActive} + className={cn( + 'flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground', + 'data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground', + size === 'sm' && 'text-xs', + size === 'md' && 'text-sm', + 'group-data-[collapsible=icon]:hidden', + className + )} + {...props} + /> + ) +}) +SidebarMenuSubButton.displayName = 'SidebarMenuSubButton' + +const Sidebar = { + Root: SidebarRoot, + Content: SidebarContent, + Footer: SidebarFooter, + Group: SidebarGroup, + GroupAction: SidebarGroupAction, + GroupContent: SidebarGroupContent, + GroupLabel: SidebarGroupLabel, + Header: SidebarHeader, + Input: SidebarInput, + Inset: SidebarInset, + Menu: SidebarMenu, + MenuAction: SidebarMenuAction, + MenuBadge: SidebarMenuBadge, + MenuButton: SidebarMenuButton, + MenuItem: SidebarMenuItem, + MenuSkeleton: SidebarMenuSkeleton, + MenuSub: SidebarMenuSubItem, + MenuSubButton: SidebarMenuSubButton, + MenuSubItem: SidebarMenuSubItem, + Provider: SidebarProvider, + Rail: SidebarRail, + Separator: SidebarSeparator, + Trigger: SidebarTrigger +} + +export { Sidebar, useSidebar } diff --git a/packages/ui/src/components/sidebar/skeleton.tsx b/packages/ui/src/components/sidebar/skeleton.tsx new file mode 100644 index 0000000000..f0eb5726a9 --- /dev/null +++ b/packages/ui/src/components/sidebar/skeleton.tsx @@ -0,0 +1,9 @@ +import { HTMLAttributes } from 'react' + +import { cn } from '@utils/cn' + +function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) { + return <div className={cn('animate-pulse rounded-md bg-primary/10', className)} {...props} /> +} + +export { Skeleton } diff --git a/packages/ui/src/components/sidebar/use-is-mobile.tsx b/packages/ui/src/components/sidebar/use-is-mobile.tsx new file mode 100644 index 0000000000..b77572f6ef --- /dev/null +++ b/packages/ui/src/components/sidebar/use-is-mobile.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined) + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) + + return !!isMobile +} diff --git a/packages/ui/src/icons/sidebar-left.svg b/packages/ui/src/icons/sidebar-left.svg new file mode 100644 index 0000000000..e0248167ff --- /dev/null +++ b/packages/ui/src/icons/sidebar-left.svg @@ -0,0 +1 @@ +<svg clip-rule="evenodd" fill-rule="evenodd" stroke-miterlimit="10" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-width="2"><path d="m9 4v16"/><path d="m20 4v16c0 1.104-.896 2-2 2h-12c-1.104 0-2-.896-2-2v-16c0-1.104.896-2 2-2h12c1.104 0 2 .896 2 2z" stroke-linecap="square" transform="matrix(-0 1 -1 -0 24 0)"/></g></svg> \ No newline at end of file diff --git a/packages/ui/src/icons/sidebar-right.svg b/packages/ui/src/icons/sidebar-right.svg new file mode 100644 index 0000000000..4d06851ae4 --- /dev/null +++ b/packages/ui/src/icons/sidebar-right.svg @@ -0,0 +1 @@ +<svg clip-rule="evenodd" fill-rule="evenodd" stroke-miterlimit="10" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-width="2"><path d="m15 4v16"/><path d="m20 4v16c0 1.104-.896 2-2 2h-12c-1.104 0-2-.896-2-2v-16c0-1.104.896-2 2-2h12c1.104 0 2 .896 2 2z" stroke-linecap="square" transform="matrix(-0 1 -1 -0 24 0)"/></g></svg> \ No newline at end of file diff --git a/packages/ui/src/views/layouts/PullRequestLayout.tsx b/packages/ui/src/views/layouts/PullRequestLayout.tsx index 78580b956d..66ce16f969 100644 --- a/packages/ui/src/views/layouts/PullRequestLayout.tsx +++ b/packages/ui/src/views/layouts/PullRequestLayout.tsx @@ -33,7 +33,7 @@ const PullRequestLayout: React.FC<PullRequestLayoutProps> = ({ return ( <SandboxLayout.Main fullWidth> - <SandboxLayout.Content className="px-6 max-w-[1500px]"> + <SandboxLayout.Content className="max-w-[1500px] px-6"> {pullRequest && ( <> <PullRequestHeader diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-header.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-header.tsx index 51ef0a5f91..31ddff8bdb 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-header.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-header.tsx @@ -64,15 +64,15 @@ export const PullRequestHeader: React.FC<PullRequestTitleProps> = ({ } }, [description, val, updateTitle]) return ( - <div className="flex flex-col gap-y-4 w-full"> - <div className="flex items-center w-full"> - <div className="flex items-center gap-x-2.5 w-full max-w-full h-[44px] text-24 font-medium text-foreground-1"> - {!edit && <div className="flex items-center h-full truncate max-w-[95%]">{original}</div>} + <div className="flex w-full flex-col gap-y-4"> + <div className="flex w-full items-center"> + <div className="flex h-[44px] w-full max-w-full items-center gap-x-2.5 text-24 font-medium text-foreground-1"> + {!edit && <div className="flex h-full max-w-[95%] items-center truncate">{original}</div>} {!edit && <span className="font-normal text-foreground-4">#{number}</span>} {edit ? ( <Layout.Horizontal className="w-full"> <input - className="rounded-md border w-fit max-w-full bg-primary-background hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + className="w-fit max-w-full rounded-md border bg-primary-background hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" // wrapperClassName={css.input} value={val} onFocus={event => event.target.select()} diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx index 80d0a78e96..540b3e3901 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-item-title.tsx @@ -47,8 +47,8 @@ export const PullRequestItemTitle: FC<PullRequestItemTitleProps> = ({ merged }) => { return ( - <div className="flex items-center gap-2 max-w-full"> - <div className="flex flex-wrap items-center justify-start gap-1.5 max-w-full"> + <div className="flex max-w-full items-center gap-2"> + <div className="flex max-w-full flex-wrap items-center justify-start gap-1.5"> <Icon size={14} className={cn({ diff --git a/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx b/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx index 306a9d1d31..2a39d16ce2 100644 --- a/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx +++ b/packages/ui/src/views/repo/pull-request/components/pull-request-list.tsx @@ -73,7 +73,7 @@ export const PullRequestList: FC<PullRequestListProps> = ({ {pullRequest.number && ( <> <StackedList.Field - className="gap-1.5 max-w-full" + className="max-w-full gap-1.5" title={ pullRequest.name && ( <PullRequestItemTitle diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx index ac2946391a..4252a735bc 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx @@ -199,7 +199,7 @@ const PullRequestCommentBox = ({ <div className="absolute inset-1 cursor-copy rounded-sm border border-dashed border-borders-2" /> )} - <div className="absolute -ml-0.5 bottom-px flex left-1/2 w-[calc(100%-16px)] -translate-x-1/2 bg-background-2 items-center pb-2 pt-1"> + <div className="absolute bottom-px left-1/2 -ml-0.5 flex w-[calc(100%-16px)] -translate-x-1/2 items-center bg-background-2 pb-2 pt-1"> {toolbar.map((item, index) => { const isFirst = index === 0 return ( @@ -240,7 +240,7 @@ const PullRequestCommentBox = ({ </div> )} - <div className="flex gap-x-3 ml-auto"> + <div className="ml-auto flex gap-x-3"> {(inReplyMode || isEditMode) && ( <Button variant="outline" onClick={onCancelClick}> Cancel diff --git a/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx b/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx index fc7900b70f..a4ea8548d4 100644 --- a/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx +++ b/packages/ui/src/views/repo/repo-summary/repo-empty-view.tsx @@ -46,7 +46,7 @@ git push -u origin main } return ( <SandboxLayout.Main> - <SandboxLayout.Content className="max-w-[850px] mx-auto"> + <SandboxLayout.Content className="mx-auto max-w-[850px]"> <Text size={5} weight={'medium'}> Repository </Text> @@ -57,7 +57,7 @@ git push -u origin main title="This repository is empty" description={['We recommend every repository include a', 'README, LICENSE, and .gitignore.']} primaryButton={{ label: 'New file' }} - className="py-0 pb-0 min-h-[40vh]" + className="min-h-[40vh] py-0" /> <Spacer size={6} /> diff --git a/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx b/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx index 59409e681d..4524099a18 100644 --- a/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx +++ b/packages/ui/src/views/repo/webhooks/webhook-list/components/repo-webhook-list.tsx @@ -100,7 +100,7 @@ export function RepoWebhookList({ <Link key={webhook.id} to={`${webhook.id}`}> <StackedList.Item key={webhook.createdAt} - className="py-3 pr-1.5 cursor-pointer" + className="cursor-pointer py-3 pr-1.5" isLast={webhooks.length - 1 === webhook_idx} > <StackedList.Field From d283fc5a5817ec501443b1d4cd8cb1eba0b3c1e6 Mon Sep 17 00:00:00 2001 From: Calvin Lee <calvin.lee@harness.io> Date: Tue, 28 Jan 2025 16:26:55 -0700 Subject: [PATCH 13/16] feat: [pipe-24076]: add preview in desc box in compare page (#859) * feat: [pipe-24076]: add preview in desc box in compare page Signed-off-by: Calvin Lee <cjlee@ualberta.ca> * feat: [pipe-24076]: add preview in desc box in compare page-p2 Signed-off-by: Calvin Lee <cjlee@ualberta.ca> * feat: [pipe-24076]: add preview in desc box in compare page-p3 Signed-off-by: Calvin Lee <cjlee@ualberta.ca> * feat: [pipe-24076]: add preview in desc box in compare page-p4 Signed-off-by: Calvin Lee <cjlee@ualberta.ca> --------- Signed-off-by: Calvin Lee <cjlee@ualberta.ca> Co-authored-by: Calvin Lee <cjlee@ualberta.ca> --- .../pull-request-compare.tsx | 2 + .../pull-request/pull-request-compare.tsx | 62 +++++- apps/gitness/src/styles/styles.css | 8 + .../src/components/markdown-viewer/style.css | 5 +- .../components/pull-request-compare-form.tsx | 176 ++++++++++++++++-- .../compare/pull-request-compare-page.tsx | 11 +- .../conversation/pull-request-comment-box.tsx | 2 +- 7 files changed, 245 insertions(+), 21 deletions(-) diff --git a/apps/design-system/src/subjects/views/pull-request-compare/pull-request-compare.tsx b/apps/design-system/src/subjects/views/pull-request-compare/pull-request-compare.tsx index 5e2283af6f..d79e65b822 100644 --- a/apps/design-system/src/subjects/views/pull-request-compare/pull-request-compare.tsx +++ b/apps/design-system/src/subjects/views/pull-request-compare/pull-request-compare.tsx @@ -25,6 +25,8 @@ const PullRequestCompareWrapper: FC<Partial<PullRequestComparePageProps>> = prop return ( <PullRequestComparePage + desc="" + setDesc={noop} handleDeleteReviewer={noop} handleAddReviewer={noop} onFormSubmit={noop} diff --git a/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx b/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx index 04bd065fbc..322c746967 100644 --- a/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx +++ b/apps/gitness/src/pages-v2/pull-request/pull-request-compare.tsx @@ -43,12 +43,14 @@ import { normalizeGitRef } from '../../utils/git-utils' import { useRepoBranchesStore } from '../repo/stores/repo-branches-store' import { useRepoCommitsStore } from '../repo/stores/repo-commits-store' import { transformBranchList } from '../repo/transform-utils/branch-transform' +import { getErrorMessage } from './pull-request-utils' /** * TODO: This code was migrated from V2 and needs to be refactored. */ export const CreatePullRequest = () => { const routes = useRoutes() + const [desc, setDesc] = useState('') const createPullRequestMutation = useCreatePullReqMutation({}) const { repoId, spaceId, diffRefs } = useParams<PathParams>() const [isBranchSelected, setIsBranchSelected] = useState<boolean>(diffRefs ? true : false) // State to track branch selection @@ -91,6 +93,57 @@ export const CreatePullRequest = () => { `${normalizeGitRef(targetRef)}...${normalizeGitRef(sourceRef)}`, [commitRange, targetRef, sourceRef] ) + + const handleUpload = (blob: File, setMarkdownContent: (data: string) => void) => { + const reader = new FileReader() + // Set up a function to be called when the load event is triggered + reader.onload = async function () { + if (blob.type.startsWith('image/') || blob.type.startsWith('video/')) { + const markdown = await uploadImage(reader.result) + if (blob.type.startsWith('image/')) { + setDesc(`![image](${markdown})`) // Set the markdown content + } else { + setMarkdownContent(markdown) // Set the markdown content + } + } + } + reader.readAsArrayBuffer(blob) // This will trigger the onload function when the reading is complete + } + const uploadImage = async ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fileBlob: any + ) => { + try { + const response = await fetch(`${window.location.origin}${`/api/v1/repos/${repoRef}/uploads`}`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'content-type': 'application/octet-stream' + }, + body: fileBlob, + redirect: 'follow' + }) + // const response = await repoArtifactUpload({ + // method: 'POST', + // headers: { 'content-type': 'application/octet-stream' }, + // body: fileBlob, + // redirect: 'follow', + // repo_ref: repoRef + // }) + + const result = await response.json() + if (!response.ok && result) { + // TODO: fix error state + console.warn(getErrorMessage(result)) + return '' + } + const filePath = result.file_path + return `${window.location.origin}/api/v1/repos/${repoRef}/uploads/${filePath}` + } catch (exception) { + console.warn(getErrorMessage(exception)) + return '' + } + } const path = useMemo(() => `/api/v1/repos/${repoRef}/+/${diffApiPath}`, [repoRef, diffApiPath]) const [sourceQuery, setSourceQuery] = useState('') @@ -287,16 +340,16 @@ export const CreatePullRequest = () => { }) useEffect(() => { - if (pullReqData?.number && pullReqData.title && pullReqData.description) { + if (pullReqData?.number && pullReqData.title) { setPrBranchCombinationExists({ number: pullReqData.number, title: pullReqData.title, - description: pullReqData.description + description: pullReqData?.description || '' }) } else { setPrBranchCombinationExists(null) } - }, [pullReqData]) + }, [pullReqData, targetRef, sourceRef]) const [query, setQuery] = useQueryState('query') // TODO:handle pagination in compare commit tab @@ -435,6 +488,9 @@ export const CreatePullRequest = () => { const renderContent = () => { return ( <PullRequestComparePage + desc={desc} + setDesc={setDesc} + handleUpload={handleUpload} toCode={({ sha }: { sha: string }) => `${routes.toRepoFiles({ spaceId, repoId })}/${sha}`} toCommitDetails={({ sha }: { sha: string }) => routes.toRepoCommitDetails({ spaceId, repoId, commitSHA: sha })} currentUser={currentUser?.display_name} diff --git a/apps/gitness/src/styles/styles.css b/apps/gitness/src/styles/styles.css index 0ea1976f30..00aabb10b3 100644 --- a/apps/gitness/src/styles/styles.css +++ b/apps/gitness/src/styles/styles.css @@ -1,3 +1,11 @@ @import '@harnessio/ui/styles.css'; @import 'highlight.js/styles/atom-one-dark.css'; + +:root { + --tab-width: 32px; +} + +.tab-width { + width: var(--tab-width); +} diff --git a/packages/ui/src/components/markdown-viewer/style.css b/packages/ui/src/components/markdown-viewer/style.css index 27da407742..8b38cafe15 100644 --- a/packages/ui/src/components/markdown-viewer/style.css +++ b/packages/ui/src/components/markdown-viewer/style.css @@ -128,7 +128,10 @@ } } } - + & img { + margin-top: 0.5em; + margin-bottom: 0.5em; + } .token { &.punctuation { color: var(--color-prettylights-syntax-string); diff --git a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-form.tsx b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-form.tsx index a575e5d1c2..0c58bf0180 100644 --- a/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-form.tsx +++ b/packages/ui/src/views/repo/pull-request/compare/components/pull-request-compare-form.tsx @@ -1,10 +1,26 @@ -import { forwardRef } from 'react' +import { forwardRef, useRef, useState } from 'react' import { FieldErrors, SubmitHandler, UseFormHandleSubmit, UseFormRegister } from 'react-hook-form' -import { Fieldset, Input, Textarea } from '@/components' -import { TranslationStore } from '@/views' +import { + Button, + Fieldset, + Icon, + Input, + MarkdownViewer, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Textarea +} from '@/components' +import { handleFileDrop, handlePaste, TranslationStore } from '@/views' +import { cn } from '@utils/cn' import { z } from 'zod' +const TABS_KEYS = { + WRITE: 'write', + PREVIEW: 'preview' +} // Define the form schema const formSchemaCompare = z.object({ title: z.string().min(1, { message: 'Please provide a pull request title' }), @@ -23,15 +39,79 @@ interface PullRequestFormProps { handleSubmit: UseFormHandleSubmit<FormFields> register: UseFormRegister<FormFields> useTranslationStore: () => TranslationStore + handleUpload?: (blob: File, setMarkdownContent: (data: string) => void) => void + desc?: string + setDesc: (desc: string) => void } const PullRequestCompareForm = forwardRef<HTMLFormElement, PullRequestFormProps>( - ({ apiError, register, handleSubmit, errors, onFormSubmit, useTranslationStore }, ref) => { + ( + { apiError, register, handleSubmit, errors, onFormSubmit, useTranslationStore, handleUpload, desc, setDesc }, + ref + ) => { const { t } = useTranslationStore() - const onSubmit: SubmitHandler<FormFields> = data => { onFormSubmit(data) } + const [__file, setFile] = useState<File>() + + const [activeTab, setActiveTab] = useState<typeof TABS_KEYS.WRITE | typeof TABS_KEYS.PREVIEW>(TABS_KEYS.WRITE) + const fileInputRef = useRef<HTMLInputElement | null>(null) + const [isDragging, setIsDragging] = useState(false) + const dropZoneRef = useRef<HTMLDivElement>(null) + + const handleUploadCallback = (file: File) => { + setFile(file) + + handleUpload?.(file, setDesc) + } + + const handleFileSelect = () => { + fileInputRef.current?.click() + } + + const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + handleUploadCallback(file) + } + } + const handleDragEnter = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.currentTarget === dropZoneRef.current) { + setIsDragging(true) + } + } + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + + if (e.currentTarget === dropZoneRef.current && !e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDragging(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + handleDropForUpload(e) + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleDropForUpload = async (event: any) => { + handleFileDrop(event, handleUploadCallback) + } + + const handlePasteForUpload = (event: React.ClipboardEvent) => { + handlePaste(event, handleUploadCallback) + } + const handleTabChange = (tab: typeof TABS_KEYS.WRITE | typeof TABS_KEYS.PREVIEW) => { + setActiveTab(tab) + } return ( <form ref={ref} onSubmit={handleSubmit(onSubmit)}> @@ -46,16 +126,82 @@ const PullRequestCompareForm = forwardRef<HTMLFormElement, PullRequestFormProps> size="md" /> - <Textarea - id="description" - {...register('description')} - placeholder={t( - 'views:pullRequests.compareChangesFormDescriptionPlaceholder', - 'Add Pull Request description here.' - )} - label={t('views:pullRequests.compareChangesFormDescriptionLabel', 'Description')} - error={errors.description?.message?.toString()} - /> + <div + className={cn('pb-5 pt-1.5 px-4 flex-1 bg-background-1 border border-borders-2 rounded-md', { + // 'border rounded-md': !inReplyMode || isEditMode, + // 'border-t': inReplyMode + })} + > + <Tabs variant="tabnav" defaultValue={TABS_KEYS.WRITE} value={activeTab} onValueChange={handleTabChange}> + <TabsList className="relative left-1/2 w-[calc(100%+var(--tab-width))] -translate-x-1/2 px-4"> + <TabsTrigger className="data-[state=active]:bg-background-1" value={TABS_KEYS.WRITE}> + Write + </TabsTrigger> + <TabsTrigger className="data-[state=active]:bg-background-1" value={TABS_KEYS.PREVIEW}> + Preview + </TabsTrigger> + </TabsList> + + <TabsContent className="mt-4" value={TABS_KEYS.WRITE}> + <div + className="relative" + onDrop={handleDrop} + onDragOver={e => e.preventDefault()} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + ref={dropZoneRef} + > + <Textarea + id="description" + {...register('description')} + value={desc} + onChange={e => { + setDesc(e.target.value) + }} + placeholder={t( + 'views:pullRequests.compareChangesFormDescriptionPlaceholder', + 'Add Pull Request description here.' + )} + onPaste={e => { + if (e.clipboardData.files.length > 0) { + handlePasteForUpload(e) + } + }} + label={t('views:pullRequests.compareChangesFormDescriptionLabel', 'Description')} + error={errors.description?.message?.toString()} + /> + {isDragging && ( + <div className="absolute inset-1 cursor-copy rounded-sm border border-dashed border-borders-2" /> + )} + </div> + </TabsContent> + <TabsContent className="mt-4" value={TABS_KEYS.PREVIEW}> + <div className="min-h-24"> + {desc ? ( + <MarkdownViewer markdownClassName="!bg-background-2" source={desc} /> + ) : ( + <span>Nothing to preview</span> + )} + </div> + </TabsContent> + </Tabs> + + <div className="mt-4 flex items-center justify-between"> + {activeTab === TABS_KEYS.WRITE && ( + <div> + <input type="file" ref={fileInputRef} className="hidden" onChange={handleFileChange} /> + <Button + className="gap-x-2 px-2.5 font-normal text-foreground-3 hover:bg-background-8" + variant="custom" + onClick={handleFileSelect} + > + <Icon size={16} name="attachment-image" /> + <span>Drag & drop, select, or paste to attach files</span> + </Button> + </div> + )} + </div> + </div> </Fieldset> {apiError && apiError !== "head branch doesn't contain any new commits." && ( diff --git a/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx b/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx index 8771fc165e..782d8c2c77 100644 --- a/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx +++ b/packages/ui/src/views/repo/pull-request/compare/pull-request-compare-page.tsx @@ -91,6 +91,9 @@ export interface PullRequestComparePageProps extends Partial<RoutingProps> { reviewers?: PRReviewer[] handleAddReviewer: (id?: number) => void handleDeleteReviewer: (id?: number) => void + handleUpload?: (blob: File, setMarkdownContent: (data: string) => void) => void + desc?: string + setDesc: (desc: string) => void } export const PullRequestComparePage: FC<PullRequestComparePageProps> = ({ @@ -123,7 +126,10 @@ export const PullRequestComparePage: FC<PullRequestComparePageProps> = ({ handleAddReviewer, handleDeleteReviewer, toCommitDetails, - toCode + toCode, + handleUpload, + desc, + setDesc }) => { const { commits: commitData } = useRepoCommitsStore() const formRef = useRef<HTMLFormElement>(null) // Create a ref for the form @@ -355,6 +361,9 @@ export const PullRequestComparePage: FC<PullRequestComparePageProps> = ({ <div className="w-full"> <Spacer size={1} /> <PullRequestCompareForm + desc={desc} + setDesc={setDesc} + handleUpload={handleUpload} register={register} ref={formRef} apiError={apiError} diff --git a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx index 4252a735bc..7586a2e68e 100644 --- a/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx +++ b/packages/ui/src/views/repo/pull-request/details/components/conversation/pull-request-comment-box.tsx @@ -164,7 +164,7 @@ const PullRequestCommentBox = ({ })} > <Tabs variant="tabnav" defaultValue={TABS_KEYS.WRITE} value={activeTab} onValueChange={handleTabChange}> - <TabsList className="relative left-1/2 w-[calc(100%+32px)] -translate-x-1/2 px-4"> + <TabsList className="relative left-1/2 w-[calc(100%+var(--tab-width))] -translate-x-1/2 px-4"> <TabsTrigger className="data-[state=active]:bg-background-2" value={TABS_KEYS.WRITE}> Write </TabsTrigger> From 105637ccad72f13442426de15d57eaaabbd4e193 Mon Sep 17 00:00:00 2001 From: Vardan Bansal <vardan.bansal@harness.io> Date: Tue, 28 Jan 2025 15:51:34 -0800 Subject: [PATCH 14/16] feat: Add Document title to pages (#845) * code clean up - move extractRedirectRouteObjects to routing/utils * integrating Document title provider * fix lint check * Cleanup * optimize implementation - review comments * rename to pageTitle * extend the type of pageTitle prop * remove doc title provider and merge into app provider * update the type in interface as well * rename hook to usePageTitle --- apps/gitness/src/AppMFE.tsx | 3 +- .../src/framework/context/AppContext.tsx | 2 + .../src/framework/hooks/usePageTitle.ts | 28 +++++++++ apps/gitness/src/framework/routing/types.ts | 7 ++- apps/gitness/src/framework/routing/utils.ts | 19 +++++- .../src/pages-v2/repo/repo-branch-list.tsx | 2 - apps/gitness/src/routes.tsx | 63 +++++++++++-------- 7 files changed, 93 insertions(+), 31 deletions(-) create mode 100644 apps/gitness/src/framework/hooks/usePageTitle.ts diff --git a/apps/gitness/src/AppMFE.tsx b/apps/gitness/src/AppMFE.tsx index b72f85e50a..1adab89e0e 100644 --- a/apps/gitness/src/AppMFE.tsx +++ b/apps/gitness/src/AppMFE.tsx @@ -17,9 +17,10 @@ import { MFEContext } from './framework/context/MFEContext' import { NavigationProvider } from './framework/context/NavigationContext' import { ThemeProvider, useThemeStore } from './framework/context/ThemeContext' import { queryClient } from './framework/queryClient' +import { extractRedirectRouteObjects } from './framework/routing/utils' import { useLoadMFEStyles } from './hooks/useLoadMFEStyles' import i18n from './i18n/i18n' -import { extractRedirectRouteObjects, mfeRoutes, repoRoutes } from './routes' +import { mfeRoutes, repoRoutes } from './routes' export interface MFERouteRendererProps { renderUrl: string diff --git a/apps/gitness/src/framework/context/AppContext.tsx b/apps/gitness/src/framework/context/AppContext.tsx index 62a1b13625..a4c24c2a80 100644 --- a/apps/gitness/src/framework/context/AppContext.tsx +++ b/apps/gitness/src/framework/context/AppContext.tsx @@ -5,6 +5,7 @@ import { noop } from 'lodash-es' import { getUser, membershipSpaces, TypesSpace, TypesUser } from '@harnessio/code-service-client' import useLocalStorage from '../hooks/useLocalStorage' +import usePageTitle from '../hooks/usePageTitle' interface AppContextType { spaces: TypesSpace[] @@ -23,6 +24,7 @@ const AppContext = createContext<AppContextType>({ }) export const AppProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + usePageTitle() const [spaces, setSpaces] = useState<TypesSpace[]>([]) const [currentUser, setCurrentUser] = useLocalStorage<TypesUser>('currentUser', {}) diff --git a/apps/gitness/src/framework/hooks/usePageTitle.ts b/apps/gitness/src/framework/hooks/usePageTitle.ts new file mode 100644 index 0000000000..83338d1a10 --- /dev/null +++ b/apps/gitness/src/framework/hooks/usePageTitle.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react' +import { useMatches } from 'react-router-dom' + +import { useTranslationStore } from '../../i18n/stores/i18n-store' +import { CustomHandle } from '../routing/types' + +const usePageTitle = () => { + const { t } = useTranslationStore() + const matches = useMatches() + + useEffect(() => { + const fullPageTitle = matches + .reduce<string[]>((titles, match) => { + const { pageTitle } = (match.handle || {}) as CustomHandle + if (typeof pageTitle === 'string') { + titles.push(pageTitle) + } else if (typeof pageTitle === 'function') { + titles.push(pageTitle(match.params)) + } + return titles + }, []) + .join(' | ') + + document.title = fullPageTitle || t('views:app.harnessOpenSource', 'Harness Open Source') + }, [matches, t]) +} + +export default usePageTitle diff --git a/apps/gitness/src/framework/routing/types.ts b/apps/gitness/src/framework/routing/types.ts index 51c50499a1..b91c51898b 100644 --- a/apps/gitness/src/framework/routing/types.ts +++ b/apps/gitness/src/framework/routing/types.ts @@ -88,7 +88,7 @@ export interface CustomHandle { /** * Defines the breadcrumb text or label using route parameters. */ - breadcrumb?: (params: Params<string>) => string + breadcrumb?: (params: Params<string>) => string | JSX.Element /** * Renders the breadcrumb as a custom React component instead of a link. @@ -99,6 +99,11 @@ export interface CustomHandle { * Associated route name for the breadcrumb. */ routeName?: string + + /** + * Updates the document title based on route parameters. + */ + pageTitle?: string | ((params: Params<string>) => string) } // Intersection of RouteObject with the custom handle diff --git a/apps/gitness/src/framework/routing/utils.ts b/apps/gitness/src/framework/routing/utils.ts index c165b9e092..7cc909fcf5 100644 --- a/apps/gitness/src/framework/routing/utils.ts +++ b/apps/gitness/src/framework/routing/utils.ts @@ -1,4 +1,4 @@ -import { Params } from 'react-router-dom' +import { Navigate, Params } from 'react-router-dom' import '../context/NavigationContext' @@ -77,3 +77,20 @@ export const getRouteMapping = ({ const routeEntries = generateRouteEntries({ routes, parentPath, parentName }) return generateRouteNameToPathFunctions(routeEntries) } + +export const extractRedirectRouteObjects = (routes: CustomRouteObject[]): CustomRouteObject[] => { + const navigateObjects: CustomRouteObject[] = [] + const traverseRoutes = (routes: CustomRouteObject[], currentPath: string = '') => { + for (const route of routes) { + const newPath = currentPath ? `${currentPath}${route.path ? `/${route.path}` : ''}` : (route.path ?? '') + if ((route.element as JSX.Element)?.type === Navigate) { + navigateObjects.push({ ...route, path: newPath }) + } + if (route.children) { + traverseRoutes(route.children, newPath) + } + } + } + traverseRoutes(routes) + return navigateObjects +} diff --git a/apps/gitness/src/pages-v2/repo/repo-branch-list.tsx b/apps/gitness/src/pages-v2/repo/repo-branch-list.tsx index 11831fb51e..49f78472ad 100644 --- a/apps/gitness/src/pages-v2/repo/repo-branch-list.tsx +++ b/apps/gitness/src/pages-v2/repo/repo-branch-list.tsx @@ -64,8 +64,6 @@ export function RepoBranchesListPage() { repo_ref: repoRef }) - console.log('searchBranches', searchBranches) - const { isLoading: isLoadingDivergence, data: { body: _branchDivergence = [] } = {}, diff --git a/apps/gitness/src/routes.tsx b/apps/gitness/src/routes.tsx index 37eb2d7bc6..4af0724da8 100644 --- a/apps/gitness/src/routes.tsx +++ b/apps/gitness/src/routes.tsx @@ -51,23 +51,6 @@ import { UserManagementPageContainer } from './pages-v2/user-management/user-man import { CreateWebhookContainer } from './pages-v2/webhooks/create-webhook-container' import WebhookListPage from './pages-v2/webhooks/webhook-list' -export const extractRedirectRouteObjects = (routes: CustomRouteObject[]): CustomRouteObject[] => { - const navigateObjects: CustomRouteObject[] = [] - const traverseRoutes = (routes: CustomRouteObject[], currentPath: string = '') => { - for (const route of routes) { - const newPath = currentPath ? `${currentPath}${route.path ? `/${route.path}` : ''}` : (route.path ?? '') - if ((route.element as JSX.Element)?.type === Navigate) { - navigateObjects.push({ ...route, path: newPath }) - } - if (route.children) { - traverseRoutes(route.children, newPath) - } - } - } - traverseRoutes(routes) - return navigateObjects -} - export const repoRoutes: CustomRouteObject[] = [ { path: 'repos', @@ -76,7 +59,13 @@ export const repoRoutes: CustomRouteObject[] = [ routeName: RouteConstants.toRepositories }, children: [ - { index: true, element: <ReposListPage /> }, + { + index: true, + element: <ReposListPage />, + handle: { + pageTitle: 'Repositories' + } + }, { path: 'create', element: <CreateRepo />, @@ -95,7 +84,8 @@ export const repoRoutes: CustomRouteObject[] = [ path: ':repoId', element: <RepoLayout />, handle: { - breadcrumb: ({ repoId }: { repoId: string }) => <Text>{repoId}</Text> + breadcrumb: ({ repoId }: { repoId: string }) => <Text>{repoId}</Text>, + pageTitle: ({ repoId }: { repoId: string }) => `Repository | ${repoId}` }, children: [ { @@ -107,7 +97,8 @@ export const repoRoutes: CustomRouteObject[] = [ element: <RepoSummaryPage />, handle: { breadcrumb: () => <Text>Summary</Text>, - routeName: RouteConstants.toRepoSummary + routeName: RouteConstants.toRepoSummary, + pageTitle: 'Summary' } }, { @@ -119,7 +110,10 @@ export const repoRoutes: CustomRouteObject[] = [ children: [ { index: true, - element: <RepoCommitsPage /> + element: <RepoCommitsPage />, + handle: { + pageTitle: 'Commits' + } }, { path: ':commitSHA', @@ -150,7 +144,8 @@ export const repoRoutes: CustomRouteObject[] = [ element: <RepoBranchesListPage />, handle: { breadcrumb: () => <Text>Branches</Text>, - routeName: RouteConstants.toRepoBranches + routeName: RouteConstants.toRepoBranches, + pageTitle: 'Branches' } }, { @@ -167,7 +162,10 @@ export const repoRoutes: CustomRouteObject[] = [ children: [ { index: true, - element: <RepoCode /> + element: <RepoCode />, + handle: { + pageTitle: 'Files' + } }, { path: '*', @@ -182,7 +180,13 @@ export const repoRoutes: CustomRouteObject[] = [ routeName: RouteConstants.toPullRequests }, children: [ - { index: true, element: <PullRequestListPage /> }, + { + index: true, + element: <PullRequestListPage />, + handle: { + pageTitle: 'Pull Requests' + } + }, { path: 'compare', handle: { @@ -260,7 +264,13 @@ export const repoRoutes: CustomRouteObject[] = [ breadcrumb: () => <Text>Pipelines</Text> }, children: [ - { index: true, element: <RepoPipelineListPage /> }, + { + index: true, + element: <RepoPipelineListPage />, + handle: { + pageTitle: 'Pipelines' + } + }, { path: ':pipelineId', handle: { @@ -319,7 +329,8 @@ export const repoRoutes: CustomRouteObject[] = [ element: <RepoSettingsGeneralPageContainer />, handle: { breadcrumb: () => <Text>General</Text>, - routeName: RouteConstants.toRepoGeneralSettings + routeName: RouteConstants.toRepoGeneralSettings, + pageTitle: 'Settings' } }, { From d66201a42658b28e5cdc6f367f485af5b1231385 Mon Sep 17 00:00:00 2001 From: Sanskar <c_sanskar.sehgal@harness.io> Date: Tue, 28 Jan 2025 20:24:36 -0800 Subject: [PATCH 15/16] feat: add multiple repo import (#846) * feat: add multiple repo import * chore: add enum * feat: add proj import * chore: clean up * fix: review comments * chore: cleanup --- apps/gitness/src/framework/routing/types.ts | 1 + .../project/project-import-container.tsx | 58 ++++ .../repo/repo-import-multiple-container.tsx | 78 ++++++ apps/gitness/src/routes.tsx | 16 ++ packages/ui/locales/en/views.json | 1 + packages/ui/locales/es/views.json | 1 + packages/ui/locales/fr/views.json | 1 + packages/ui/src/views/project/index.ts | 3 + .../ui/src/views/project/project-import.tsx | 248 ++++++++++++++++++ packages/ui/src/views/repo/index.ts | 2 + .../repo/repo-import/repo-import-mulitple.tsx | 215 +++++++++++++++ .../views/repo/repo-import/repo-import.tsx | 24 +- .../ui/src/views/repo/repo-import/types.ts | 11 + .../views/repo/repo-list/repo-list-page.tsx | 12 +- 14 files changed, 654 insertions(+), 17 deletions(-) create mode 100644 apps/gitness/src/pages-v2/project/project-import-container.tsx create mode 100644 apps/gitness/src/pages-v2/repo/repo-import-multiple-container.tsx create mode 100644 packages/ui/src/views/project/project-import.tsx create mode 100644 packages/ui/src/views/repo/repo-import/repo-import-mulitple.tsx create mode 100644 packages/ui/src/views/repo/repo-import/types.ts diff --git a/apps/gitness/src/framework/routing/types.ts b/apps/gitness/src/framework/routing/types.ts index b91c51898b..f83ce3aa21 100644 --- a/apps/gitness/src/framework/routing/types.ts +++ b/apps/gitness/src/framework/routing/types.ts @@ -7,6 +7,7 @@ export enum RouteConstants { toSignIn = 'toSignIn', toCreateRepo = 'toCreateRepo', toImportRepo = 'toImportRepo', + toImportMultipleRepos = 'toImportMultipleRepos', toRepositories = 'toRepositories', toRepoSummary = 'toRepoSummary', toRepoCommits = 'toRepoCommits', diff --git a/apps/gitness/src/pages-v2/project/project-import-container.tsx b/apps/gitness/src/pages-v2/project/project-import-container.tsx new file mode 100644 index 0000000000..d6e1a4ba47 --- /dev/null +++ b/apps/gitness/src/pages-v2/project/project-import-container.tsx @@ -0,0 +1,58 @@ +import { useNavigate } from 'react-router-dom' + +import { ImporterProviderType, ImportSpaceRequestBody, useImportSpaceMutation } from '@harnessio/code-service-client' +import { ImportProjectFormFields, ImportProjectPage, ProviderOptionsEnum } from '@harnessio/ui/views' + +import { useRoutes } from '../../framework/context/NavigationContext' + +export const ImportProjectContainer = () => { + const routes = useRoutes() + const navigate = useNavigate() + + const { + mutate: importProjectMutation, + error, + isLoading + } = useImportSpaceMutation( + {}, + { + onSuccess: data => { + navigate(routes.toRepositories({ spaceId: data.body?.identifier })) + } + } + ) + + const onSubmit = async (data: ImportProjectFormFields) => { + const body: ImportSpaceRequestBody = { + identifier: data.identifier, + description: data.description, + pipelines: data.pipelines === true ? 'convert' : 'ignore', + provider: { + host: data.hostUrl ?? '', + password: data.password, + type: + data.provider === ProviderOptionsEnum.GITHUB || data.provider === ProviderOptionsEnum.GITHUB_ENTERPRISE + ? (ProviderOptionsEnum.GITHUB.toLocaleLowerCase() as ImporterProviderType) + : undefined + }, + provider_space: data.organization + } + importProjectMutation({ + queryParams: {}, + body: body + }) + } + + const onCancel = () => { + navigate(-1) + } + + return ( + <ImportProjectPage + onFormSubmit={onSubmit} + onFormCancel={onCancel} + isLoading={isLoading} + apiErrorsValue={error?.message?.toString()} + /> + ) +} diff --git a/apps/gitness/src/pages-v2/repo/repo-import-multiple-container.tsx b/apps/gitness/src/pages-v2/repo/repo-import-multiple-container.tsx new file mode 100644 index 0000000000..0a5b746883 --- /dev/null +++ b/apps/gitness/src/pages-v2/repo/repo-import-multiple-container.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' + +import { ImporterProviderType, ImportSpaceRequestBody } from '@harnessio/code-service-client' +import { ImportMultipleReposFormFields, ProviderOptionsEnum, RepoImportMultiplePage } from '@harnessio/ui/views' + +import { useRoutes } from '../../framework/context/NavigationContext' +import { useGetSpaceURLParam } from '../../framework/hooks/useGetSpaceParam' +import { PathParams } from '../../RouteDefinitions' + +export const ImportMultipleRepos = () => { + const routes = useRoutes() + const { spaceId } = useParams<PathParams>() + const spaceURL = useGetSpaceURLParam() + const navigate = useNavigate() + const [apiError, setApiError] = useState<string>('') + + const onSubmit = async (data: ImportMultipleReposFormFields) => { + const body: ImportSpaceRequestBody = { + identifier: spaceURL, + description: '', + parent_ref: spaceURL, + pipelines: data.pipelines === true ? 'convert' : 'ignore', + provider: { + host: data.hostUrl ?? '', + password: data.password, + type: + data.provider === ProviderOptionsEnum.GITHUB || data.provider === ProviderOptionsEnum.GITHUB_ENTERPRISE + ? (ProviderOptionsEnum.GITHUB.toLocaleLowerCase() as ImporterProviderType) + : undefined + }, + provider_space: data.organization + } + + try { + const response = await fetch(`/api/v1/spaces/${spaceId}/+/import`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }) + + if (!response.ok) { + const errorData = await response.json() + + // temporary solution to handle unauthorized requests + + if (response.status === 401) { + navigate('/login') + } + + setApiError(errorData.message || 'Failed to import space') + + return + } + navigate(routes.toRepositories({ spaceId })) + } catch (error) { + setApiError((error as Error).message || 'An unexpected error occurred') + } + } + + const onCancel = () => { + navigate(routes.toRepositories({ spaceId })) + } + + return ( + // @TODO: Add loading states and error handling when API is available + <> + <RepoImportMultiplePage + onFormSubmit={onSubmit} + onFormCancel={onCancel} + isLoading={false} + apiErrorsValue={apiError} + /> + </> + ) +} diff --git a/apps/gitness/src/routes.tsx b/apps/gitness/src/routes.tsx index 4af0724da8..090cbd6f9f 100644 --- a/apps/gitness/src/routes.tsx +++ b/apps/gitness/src/routes.tsx @@ -19,6 +19,7 @@ import { SettingsProfileKeysPage } from './pages-v2/profile-settings/profile-set import { ProfileSettingsThemePage } from './pages-v2/profile-settings/profile-settings-theme-page' import { SettingsLayout as ProfileSettingsLayout } from './pages-v2/profile-settings/settings-layout' import { ProjectGeneralSettingsPageContainer } from './pages-v2/project/project-general-settings-container' +import { ImportProjectContainer } from './pages-v2/project/project-import-container' import { ProjectLabelsList } from './pages-v2/project/project-labels-list-container' import { ProjectMemberListPage } from './pages-v2/project/project-member-list' import { SettingsLayout as ProjectSettingsLayout } from './pages-v2/project/settings-layout' @@ -37,6 +38,7 @@ import { CommitDiffContainer } from './pages-v2/repo/repo-commit-details-diff' import RepoCommitsPage from './pages-v2/repo/repo-commits' import { CreateRepo } from './pages-v2/repo/repo-create-page' import RepoExecutionListPage from './pages-v2/repo/repo-execution-list' +import { ImportMultipleRepos } from './pages-v2/repo/repo-import-multiple-container' import { ImportRepo } from './pages-v2/repo/repo-import-page' import { RepoLabelsList } from './pages-v2/repo/repo-labels-container' import RepoLayout from './pages-v2/repo/repo-layout' @@ -80,6 +82,13 @@ export const repoRoutes: CustomRouteObject[] = [ routeName: RouteConstants.toImportRepo } }, + { + path: 'import-multiple', + element: <ImportMultipleRepos />, + handle: { + routeName: RouteConstants.toImportMultipleRepos + } + }, { path: ':repoId', element: <RepoLayout />, @@ -466,6 +475,13 @@ export const routes: CustomRouteObject[] = [ }, children: [] }, + { + path: 'import', + element: <ImportProjectContainer />, + handle: { + breadcrumb: () => <Text>Import project</Text> + } + }, { path: 'repos', element: ( diff --git a/packages/ui/locales/en/views.json b/packages/ui/locales/en/views.json index c7e178b967..297ff20674 100644 --- a/packages/ui/locales/en/views.json +++ b/packages/ui/locales/en/views.json @@ -92,6 +92,7 @@ "emptyRepo": "This repository is empty.", "repositories": "Repositories", "import-repository": "Import repository", + "import-repositories": "Import repositories", "create-repository": "Create repository", "private": "Private", "public": "Public", diff --git a/packages/ui/locales/es/views.json b/packages/ui/locales/es/views.json index 480f65110d..7baf302a9b 100644 --- a/packages/ui/locales/es/views.json +++ b/packages/ui/locales/es/views.json @@ -92,6 +92,7 @@ "emptyRepo": "", "repositories": "", "import-repository": "Import repository", + "import-repositories": "Import repositories", "create-repository": "", "private": "Private", "public": "Public", diff --git a/packages/ui/locales/fr/views.json b/packages/ui/locales/fr/views.json index 393535ecc5..6c86dff979 100644 --- a/packages/ui/locales/fr/views.json +++ b/packages/ui/locales/fr/views.json @@ -92,6 +92,7 @@ "emptyRepo": "Ce référentiel est vide.", "repositories": "Dépôts", "import-repository": "Importer un dépôt", + "import-repositories": "Import repositories", "create-repository": "Créer un dépôt", "private": "Privé", "public": "Public", diff --git a/packages/ui/src/views/project/index.ts b/packages/ui/src/views/project/index.ts index 0aa12711cf..a47c31a7ba 100644 --- a/packages/ui/src/views/project/index.ts +++ b/packages/ui/src/views/project/index.ts @@ -13,3 +13,6 @@ export * from '@views/project/project-labels/components' export * from '@views/project/project.types' export * from './project-settings-page' export * from './project-labels/types' + +// project import +export * from '@views/project/project-import' diff --git a/packages/ui/src/views/project/project-import.tsx b/packages/ui/src/views/project/project-import.tsx new file mode 100644 index 0000000000..335e2e14a6 --- /dev/null +++ b/packages/ui/src/views/project/project-import.tsx @@ -0,0 +1,248 @@ +import { useEffect } from 'react' +import { useForm, type SubmitHandler } from 'react-hook-form' + +import { + Button, + ButtonGroup, + Checkbox, + ControlGroup, + Fieldset, + FormSeparator, + FormWrapper, + Input, + Option, + Select, + SelectContent, + SelectItem, + Spacer, + Text +} from '@/components' +import { SandboxLayout } from '@/views' +import { zodResolver } from '@hookform/resolvers/zod' +import { ProviderOptionsEnum } from '@views/repo/repo-import/types' +import { z } from 'zod' + +const formSchema = z + .object({ + identifier: z.string(), + description: z.string(), + hostUrl: z.string().optional(), + pipelines: z.boolean().optional(), + repositories: z.boolean().optional(), + provider: z.string().min(1, { message: 'Please select a provider' }), + password: z.string().optional(), + organization: z.string().min(1, { message: 'Please enter an organization' }) + }) + .superRefine((data, ctx) => { + if (data.provider === 'Github Enterprise' && !data.hostUrl) { + ctx.addIssue({ + code: 'custom', + path: ['hostUrl'], + message: 'Repository URL is required' + }) + } + }) + +export type ImportProjectFormFields = z.infer<typeof formSchema> + +interface ImportProjectPageProps { + onFormSubmit: (data: ImportProjectFormFields) => void + onFormCancel: () => void + isLoading: boolean + apiErrorsValue?: string +} + +export function ImportProjectPage({ onFormSubmit, onFormCancel, isLoading, apiErrorsValue }: ImportProjectPageProps) { + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors } + } = useForm<ImportProjectFormFields>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + identifier: '', + description: '', + pipelines: false, + repositories: true, + provider: 'Github', + password: '', + organization: '' + } + }) + + const providerValue = watch('provider') + const orgValue = watch('organization') + + useEffect(() => { + setValue('identifier', orgValue) + }, [orgValue, setValue]) + + const handleSelectChange = (fieldName: keyof ImportProjectFormFields, value: string) => { + setValue(fieldName, value, { shouldValidate: true }) + } + + const onSubmit: SubmitHandler<ImportProjectFormFields> = data => { + onFormSubmit(data) + } + + const handleCancel = () => { + onFormCancel() + } + + return ( + <SandboxLayout.Main> + <SandboxLayout.Content paddingClassName="w-[570px] mx-auto pt-11 pb-20"> + <Spacer size={5} /> + <Text className="tracking-tight" size={5} weight="medium"> + Import a Project + </Text> + <Spacer size={10} /> + <FormWrapper onSubmit={handleSubmit(onSubmit)}> + {/* provider */} + <Fieldset> + <ControlGroup> + <Select + name="provider" + value={providerValue} + onValueChange={value => handleSelectChange('provider', value)} + placeholder="Select" + label="Git provider" + > + <SelectContent> + {ProviderOptionsEnum && + Object.values(ProviderOptionsEnum)?.map(option => { + return ( + <SelectItem + key={option} + value={option} + disabled={ + option !== ProviderOptionsEnum.GITHUB && option !== ProviderOptionsEnum.GITHUB_ENTERPRISE + } + > + {option} + </SelectItem> + ) + })} + </SelectContent> + </Select> + </ControlGroup> + </Fieldset> + {watch('provider') === ProviderOptionsEnum.GITHUB_ENTERPRISE && ( + <Fieldset> + <Input + id="host" + label="Host URL" + {...register('hostUrl')} + placeholder="Enter the host URL" + size="md" + error={errors.hostUrl?.message?.toString()} + /> + </Fieldset> + )} + + {/* token */} + <Fieldset> + <ControlGroup> + <Input + type="password" + id="password" + label="Token" + {...register('password')} + placeholder="Enter your access token" + size="md" + error={errors.password?.message?.toString()} + /> + </ControlGroup> + </Fieldset> + + <FormSeparator /> + + {/* organization */} + <Fieldset> + <Input + id="organization" + label="Organization" + {...register('organization')} + placeholder="Enter the organization name" + size="md" + error={errors.organization?.message?.toString()} + /> + </Fieldset> + + {/* authorization - pipelines */} + <Fieldset> + <ControlGroup className="flex flex-row gap-5"> + <Option + control={<Checkbox {...register('repositories')} id="authorization" checked={true} disabled />} + id="authorization" + label="Repositories" + className="mt-0 flex min-h-8 items-center" + /> + <Option + control={ + <Checkbox + {...register('pipelines')} + id="pipelines" + checked={watch('pipelines')} + onCheckedChange={(checked: boolean) => setValue('pipelines', checked)} + /> + } + id="pipelines" + label="Import Pipelines" + className="mt-0 flex min-h-8 items-center" + /> + </ControlGroup> + </Fieldset> + + <FormSeparator /> + + {/* project identifier */} + <Fieldset> + <ControlGroup> + <Input + id="identifier" + label="Name" + {...register('identifier')} + placeholder="Enter repository name" + size="md" + error={errors.identifier?.message?.toString()} + /> + </ControlGroup> + </Fieldset> + + {/* description */} + <Fieldset> + <ControlGroup> + <Input + id="description" + label="Description" + {...register('description')} + placeholder="Enter a description" + size="md" + error={errors.description?.message?.toString()} + /> + </ControlGroup> + </Fieldset> + + {!!apiErrorsValue && <span className="text-xs text-destructive">{apiErrorsValue}</span>} + {/* SUBMIT BUTTONS */} + <Fieldset> + <ControlGroup> + <ButtonGroup> + <Button type="submit" disabled={isLoading}> + {!isLoading ? 'Import project' : 'Importing project...'} + </Button> + <Button type="button" variant="outline" onClick={handleCancel}> + Cancel + </Button> + </ButtonGroup> + </ControlGroup> + </Fieldset> + </FormWrapper> + </SandboxLayout.Content> + </SandboxLayout.Main> + ) +} diff --git a/packages/ui/src/views/repo/index.ts b/packages/ui/src/views/repo/index.ts index 25c62b77c4..06476debe5 100644 --- a/packages/ui/src/views/repo/index.ts +++ b/packages/ui/src/views/repo/index.ts @@ -38,6 +38,8 @@ export * from '@views/repo/repo-settings/types' // repo import export * from '@views/repo/repo-import/repo-import' +export * from '@views/repo/repo-import/repo-import-mulitple' +export * from '@views/repo/repo-import/types' // repo branch rules export * from '@views/repo/repo-branch-rules' diff --git a/packages/ui/src/views/repo/repo-import/repo-import-mulitple.tsx b/packages/ui/src/views/repo/repo-import/repo-import-mulitple.tsx new file mode 100644 index 0000000000..71b339c4b5 --- /dev/null +++ b/packages/ui/src/views/repo/repo-import/repo-import-mulitple.tsx @@ -0,0 +1,215 @@ +import { useForm, type SubmitHandler } from 'react-hook-form' + +import { + Button, + ButtonGroup, + Checkbox, + ControlGroup, + Fieldset, + FormSeparator, + FormWrapper, + Input, + Option, + Select, + SelectContent, + SelectItem, + Spacer, + Text +} from '@/components' +import { SandboxLayout } from '@/views' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' + +import { ProviderOptionsEnum } from './types' + +const formSchema = z + .object({ + hostUrl: z.string().optional(), + pipelines: z.boolean().optional(), + repositories: z.boolean().optional(), + provider: z.string().min(1, { message: 'Please select a provider' }), + password: z.string().optional(), + organization: z.string().min(1, { message: 'Please enter an organization' }) + }) + .superRefine((data, ctx) => { + if (data.provider === 'Github Enterprise' && !data.hostUrl) { + ctx.addIssue({ + code: 'custom', + path: ['hostUrl'], + message: 'Repository URL is required' + }) + } + }) + +export type ImportMultipleReposFormFields = z.infer<typeof formSchema> + +interface RepoImportMultiplePageProps { + onFormSubmit: (data: ImportMultipleReposFormFields) => void + onFormCancel: () => void + isLoading: boolean + apiErrorsValue?: string +} + +export function RepoImportMultiplePage({ + onFormSubmit, + onFormCancel, + isLoading, + apiErrorsValue +}: RepoImportMultiplePageProps) { + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors } + } = useForm<ImportMultipleReposFormFields>({ + resolver: zodResolver(formSchema), + mode: 'onChange', + defaultValues: { + pipelines: false, + repositories: true, + provider: 'Github', + password: '', + organization: '' + } + }) + + const providerValue = watch('provider') + + const handleSelectChange = (fieldName: keyof ImportMultipleReposFormFields, value: string) => { + setValue(fieldName, value, { shouldValidate: true }) + } + + const onSubmit: SubmitHandler<ImportMultipleReposFormFields> = data => { + onFormSubmit(data) + } + + const handleCancel = () => { + onFormCancel() + } + + return ( + <SandboxLayout.Main> + <SandboxLayout.Content paddingClassName="w-[570px] mx-auto pt-11 pb-20"> + <Spacer size={5} /> + <Text className="tracking-tight" size={5} weight="medium"> + Import Repositories + </Text> + <Spacer size={10} /> + <FormWrapper onSubmit={handleSubmit(onSubmit)}> + {/* provider */} + <Fieldset> + <ControlGroup> + <Select + name="provider" + value={providerValue} + onValueChange={value => handleSelectChange('provider', value)} + placeholder="Select" + label="Git provider" + > + <SelectContent> + {ProviderOptionsEnum && + Object.values(ProviderOptionsEnum)?.map(option => { + return ( + <SelectItem + key={option} + value={option} + disabled={ + option !== ProviderOptionsEnum.GITHUB && option !== ProviderOptionsEnum.GITHUB_ENTERPRISE + } + > + {option} + </SelectItem> + ) + })} + </SelectContent> + </Select> + </ControlGroup> + </Fieldset> + {watch('provider') === ProviderOptionsEnum.GITHUB_ENTERPRISE && ( + <Fieldset> + <Input + id="host" + label="Host URL" + {...register('hostUrl')} + placeholder="Enter the host URL" + size="md" + error={errors.hostUrl?.message?.toString()} + /> + </Fieldset> + )} + + {/* token */} + <Fieldset> + <ControlGroup> + <Input + type="password" + id="password" + label="Token" + {...register('password')} + placeholder="Enter your access token" + size="md" + error={errors.password?.message?.toString()} + /> + </ControlGroup> + </Fieldset> + + <FormSeparator /> + + {/* organization */} + <Fieldset> + <Input + id="organization" + label="Organization" + {...register('organization')} + placeholder="Enter the organization name" + size="md" + error={errors.organization?.message?.toString()} + /> + </Fieldset> + + {/* authorization - pipelines */} + <Fieldset> + <ControlGroup className="flex flex-row gap-5"> + <Option + control={<Checkbox {...register('repositories')} id="authorization" checked={true} disabled />} + id="authorization" + label="Repositories" + className="mt-0 flex min-h-8 items-center" + /> + <Option + control={ + <Checkbox + {...register('pipelines')} + id="pipelines" + checked={watch('pipelines')} + onCheckedChange={(checked: boolean) => setValue('pipelines', checked)} + /> + } + id="pipelines" + label="Import Pipelines" + className="mt-0 flex min-h-8 items-center" + /> + </ControlGroup> + </Fieldset> + + {!!apiErrorsValue && <span className="text-xs text-destructive">{apiErrorsValue}</span>} + {/* SUBMIT BUTTONS */} + <Fieldset> + <ControlGroup> + <ButtonGroup> + {/* TODO: Improve loading state to avoid flickering */} + <Button type="submit" disabled={isLoading}> + {!isLoading ? 'Import repositories' : 'Importing repositories...'} + </Button> + <Button type="button" variant="outline" onClick={handleCancel}> + Cancel + </Button> + </ButtonGroup> + </ControlGroup> + </Fieldset> + </FormWrapper> + </SandboxLayout.Content> + </SandboxLayout.Main> + ) +} diff --git a/packages/ui/src/views/repo/repo-import/repo-import.tsx b/packages/ui/src/views/repo/repo-import/repo-import.tsx index 4f3e4756bb..baf4270e22 100644 --- a/packages/ui/src/views/repo/repo-import/repo-import.tsx +++ b/packages/ui/src/views/repo/repo-import/repo-import.tsx @@ -21,6 +21,8 @@ import { SandboxLayout } from '@/views' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' +import { ProviderOptionsEnum } from './types' + const formSchema = z .object({ identifier: z.string(), @@ -52,18 +54,6 @@ interface RepoImportPageProps { apiErrorsValue?: string } -const providerOptions = [ - `Github`, - `Github Enterprise`, - `Gitlab`, - `Gitlab Self-Hosted`, - `Bitbucket`, - `Bitbucket Server`, - `Gitea`, - `Gogs`, - `Azure DevOps` -] - export function RepoImportPage({ onFormSubmit, onFormCancel, isLoading, apiErrorsValue }: RepoImportPageProps) { const { register, @@ -125,13 +115,15 @@ export function RepoImportPage({ onFormSubmit, onFormCancel, isLoading, apiError label="Provider" > <SelectContent> - {providerOptions && - providerOptions?.map(option => { + {ProviderOptionsEnum && + Object.values(ProviderOptionsEnum)?.map(option => { return ( <SelectItem key={option} value={option} - disabled={option !== 'Github' && option !== `Github Enterprise`} + disabled={ + option !== ProviderOptionsEnum.GITHUB && option !== ProviderOptionsEnum.GITHUB_ENTERPRISE + } > {option} </SelectItem> @@ -142,7 +134,7 @@ export function RepoImportPage({ onFormSubmit, onFormCancel, isLoading, apiError </ControlGroup> </Fieldset> - {watch('provider') === 'Github Enterprise' && ( + {watch('provider') === ProviderOptionsEnum.GITHUB_ENTERPRISE && ( <Fieldset className="mt-4"> <Input id="host" diff --git a/packages/ui/src/views/repo/repo-import/types.ts b/packages/ui/src/views/repo/repo-import/types.ts new file mode 100644 index 0000000000..1768a7e950 --- /dev/null +++ b/packages/ui/src/views/repo/repo-import/types.ts @@ -0,0 +1,11 @@ +export enum ProviderOptionsEnum { + GITHUB = 'Github', + GITHUB_ENTERPRISE = 'Github Enterprise', + GITLAB = 'Gitlab', + GITLAB_SELF_HOSTED = 'Gitlab Self-Hosted', + BITBUCKET = 'Bitbucket', + BITBUCKET_SERVER = 'Bitbucket Server', + GITEA = 'Gitea', + GOGS = 'Gogs', + AZURE_DEVOPS = 'Azure DevOps' +} diff --git a/packages/ui/src/views/repo/repo-list/repo-list-page.tsx b/packages/ui/src/views/repo/repo-list/repo-list-page.tsx index b1637ccba8..149d8241c4 100644 --- a/packages/ui/src/views/repo/repo-list/repo-list-page.tsx +++ b/packages/ui/src/views/repo/repo-list/repo-list-page.tsx @@ -152,11 +152,21 @@ const SandboxRepoListPage: FC<RepoListProps> = ({ id="repository" dropdownContentClassName="mt-0 min-w-[170px]" handleButtonClick={() => navigate(toCreateRepo?.() || '')} - handleOptionChange={() => navigate(toImportRepo?.() || '')} + handleOptionChange={option => { + if (option === 'import') { + navigate(toImportRepo?.() || '') + } else if (option === 'import-multiple') { + navigate('import-multiple') + } + }} options={[ { value: 'import', label: t('views:repos.import-repository', 'Import repository') + }, + { + value: 'import-multiple', + label: t('views:repos.import-repositories', 'Import repositories') } ]} > From 0397383c597aef1940737c151839307d5b2c0ab7 Mon Sep 17 00:00:00 2001 From: Abhinav Rastogi <abhinav.rastogi@harness.io> Date: Wed, 29 Jan 2025 00:45:11 -0800 Subject: [PATCH 16/16] Filters package (#811) * Added filters package * added utilities * updated filters package * filters updated * updated filters * improved types * export types * export createFilters method * fix: fixed ref and reset filter issue * fix: added ts ignore on router * fix: updated filters rendering according to order (#826) * fix: updated filters rendering according to order * feat: added support for defaultValues to filters * Enhancements on Filters (#837) * fix: updated filters rendering according to order * feat: added support for defaultValues to filters * feat: added default value support * feat: added support for default value on-reset * fix: addressed review comments * feat: add onchange for filters * fix: update type issues (#849) * fix: update type issues * fix: update display-name ts issue * removed tsup and added vite config * fix: fixed prettier * updated lock file * updated useRouter --------- Co-authored-by: avinashmadhwani02 <avinash.madhwani@harness.io> Co-authored-by: Harish V <164648608+harish-viswa@users.noreply.github.com> --- packages/filters/package.json | 54 ++++ packages/filters/src/Filter.tsx | 59 ++++ packages/filters/src/Filters.tsx | 362 +++++++++++++++++++++++ packages/filters/src/FiltersContent.tsx | 47 +++ packages/filters/src/FiltersDropdown.tsx | 23 ++ packages/filters/src/debug.ts | 59 ++++ packages/filters/src/index.ts | 6 + packages/filters/src/parsers.ts | 57 ++++ packages/filters/src/types.ts | 34 +++ packages/filters/src/useRouter.ts | 84 ++++++ packages/filters/src/useSearchParams.ts | 26 ++ packages/filters/src/utils.ts | 74 +++++ packages/filters/tsconfig.json | 20 ++ packages/filters/vite.config.ts | 31 ++ pnpm-lock.yaml | 62 +++- 15 files changed, 996 insertions(+), 2 deletions(-) create mode 100644 packages/filters/package.json create mode 100644 packages/filters/src/Filter.tsx create mode 100644 packages/filters/src/Filters.tsx create mode 100644 packages/filters/src/FiltersContent.tsx create mode 100644 packages/filters/src/FiltersDropdown.tsx create mode 100644 packages/filters/src/debug.ts create mode 100644 packages/filters/src/index.ts create mode 100644 packages/filters/src/parsers.ts create mode 100644 packages/filters/src/types.ts create mode 100644 packages/filters/src/useRouter.ts create mode 100644 packages/filters/src/useSearchParams.ts create mode 100644 packages/filters/src/utils.ts create mode 100644 packages/filters/tsconfig.json create mode 100644 packages/filters/vite.config.ts diff --git a/packages/filters/package.json b/packages/filters/package.json new file mode 100644 index 0000000000..62a78b6a24 --- /dev/null +++ b/packages/filters/package.json @@ -0,0 +1,54 @@ +{ + "name": "@harnessio/filters", + "version": "1.0.0", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "dev": "run-p build:watch", + "build": "vite build", + "build:watch": "vite build --watch", + "prepublishOnly": "pnpm build", + "pretty": "prettier --check ./src", + "pre-commit": "lint-staged" + }, + "private": false, + "type": "module", + "module": "./dist/index.js", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/index.js" + } + }, + "peerDependencies": { + "react": ">=17.0.0 <19.0.0", + "react-dom": ">=17.0.0 <19.0.0", + "react-router-dom": ">=5.0.0 <7.0.0" + }, + "devDependencies": { + "@types/node": "^16.18.84", + "@types/react": "^17.0.3", + "@types/react-dom": "^17.0.3", + "@vitejs/plugin-react-swc": "^3.7.2", + "dts-bundle-generator": "^6.4.0", + "eslint": "^8.57.1", + "flatted": "^3.3.2", + "jest": "^29.7.0", + "lint-staged": "^15.2.9", + "npm-run-all": "^4.1.5", + "ts-jest": "^29.1.2", + "typescript": "^5.3.3", + "vite": "^6.0.3", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-svgr": "^4.3.0" + }, + "license": "Apache-2.0", + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint ./src --fix", + "prettier ./src --write" + ] + } +} diff --git a/packages/filters/src/Filter.tsx b/packages/filters/src/Filter.tsx new file mode 100644 index 0000000000..22eabc4d53 --- /dev/null +++ b/packages/filters/src/Filter.tsx @@ -0,0 +1,59 @@ +import React from 'react' + +import { useFiltersContext } from './Filters' +import { defaultStringParser } from './parsers' + +type Parser<T> = { + parse: (value: string) => T + serialize: (value: T) => string +} + +export interface FilterProps<T, K extends keyof T> { + filterKey: K + children: (props: { + onChange: (value: T[K]) => void + value?: Parser<T[K]> extends undefined ? string : T[K] + removeFilter: (filterKey?: K) => void + }) => React.ReactNode + parser?: Parser<T[K]> + sticky?: boolean + className?: string +} + +const Filter = <T, K extends keyof T>({ + filterKey, + children, + parser = defaultStringParser as Parser<T[K]>, + className +}: FilterProps<T, K>): React.ReactElement | null => { + const { updateFilter, getFilterValue, removeFilter } = useFiltersContext<any>() + + // Handles when a new value is set + const handleChange = (value: T[K]) => { + const serializedValue = parser.serialize(value) + updateFilter(filterKey as string, serializedValue, value) + } + + // If no filter key is provided, + // filterKey provided to component will be used + const wrappedRemoveFilter = (fkey?: K) => { + removeFilter(fkey ?? filterKey) + } + + // Retrieves the raw and parsed filter value + const rawValue = getFilterValue(filterKey as string) + const parsedValue = rawValue as T + + // Render the children with the injected props + return ( + <div id="filter" className={className}> + {children({ + onChange: handleChange, + value: parsedValue as T[K], + removeFilter: wrappedRemoveFilter + })} + </div> + ) +} + +export default Filter diff --git a/packages/filters/src/Filters.tsx b/packages/filters/src/Filters.tsx new file mode 100644 index 0000000000..ee363b1e6b --- /dev/null +++ b/packages/filters/src/Filters.tsx @@ -0,0 +1,362 @@ +import React, { + createContext, + forwardRef, + ReactNode, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState +} from 'react' + +import { debug, warn } from './debug' +import Filter, { FilterProps } from './Filter' +import FiltersContent, { FiltersContentProps } from './FiltersContent' +import FiltersDropdown, { FiltersDropdownProps } from './FiltersDropdown' +import { FilterConfig, FilterRefType, FilterStatus, FilterType, InitializeFiltersConfigType } from './types' +import useRouter from './useRouter' +import { createQueryString, isNullable, mergeURLSearchParams } from './utils' + +interface FiltersContextType<T extends Record<string, unknown>> { + visibleFilters: (keyof T)[] + availableFilters: (keyof T)[] + removeFilter: (filterKey: keyof T) => void + resetFilters: () => void + addFilter: (filterKey: keyof T) => void + getFilterValue: (filterKey: keyof T) => any + updateFilter: (filterKey: keyof T, parsedValue: any, value: any) => void + addInitialFilters: (filtersConfig: Record<keyof T, InitializeFiltersConfigType<T>>) => void +} + +const FiltersContext = createContext<FiltersContextType<Record<string, unknown>> | null>(null) + +interface FiltersProps<T extends Record<string, unknown>> { + children?: ReactNode + allFiltersSticky?: boolean + onChange?: (filters: T) => void +} + +const Filters = forwardRef(function Filters<T extends Record<string, unknown>>( + { children, allFiltersSticky, onChange }: FiltersProps<T>, + ref: React.Ref<FilterRefType<T>> +) { + type FilterKeys = keyof T + const [filtersOrder, setFiltersOrder] = useState<FilterKeys[]>([]) + const [filtersMap, setFiltersMap] = useState<Record<FilterKeys, FilterType>>({} as Record<FilterKeys, FilterType>) + const [filtersConfig, setFiltersConfig] = useState<Record<FilterKeys, FilterConfig>>( + {} as Record<FilterKeys, FilterConfig> + ) + const { searchParams, updateURL: routerUpdateURL } = useRouter() + const initialFiltersRef = useRef<Record<FilterKeys, FilterType> | undefined>(undefined) + + const updateURL = (params: URLSearchParams) => { + // merge params into search params and update the URL. + const paramsOtherthanFilters: URLSearchParams = new URLSearchParams() + searchParams.forEach((value, key) => { + if (!filtersMap[key as FilterKeys]) { + paramsOtherthanFilters.append(key, value) + } + }) + const mergedParams = mergeURLSearchParams(paramsOtherthanFilters, params) + routerUpdateURL(mergedParams) + } + + const setFiltersMapTrigger = (filtersMap: Record<FilterKeys, FilterType>) => { + setFiltersMap(filtersMap) + onChange?.(getValues(filtersMap)) + } + + const addFilter = (filterKey: FilterKeys) => { + debug('Adding filter with key: %s', filterKey) + setFiltersMap(prev => ({ + ...prev, + [filterKey]: createNewFilter() + })) + + onChange?.( + getValues({ + ...filtersMap, + [filterKey]: createNewFilter() + }) + ) + setFiltersOrder(prev => [...prev, filterKey]) + } + + const removeFilter = (filterKey: FilterKeys) => { + debug('Removing filter with key: %s', filterKey) + const updatedFiltersMap = { ...filtersMap } + delete updatedFiltersMap[filterKey] + const updatedFiltersOrder = filtersOrder.filter(key => key !== filterKey) + setFiltersMapTrigger(updatedFiltersMap) + setFiltersOrder(updatedFiltersOrder) + + const query = createQueryString(updatedFiltersOrder, updatedFiltersMap) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + } + + const updateFilter = (filterKey: FilterKeys, parsedValue: any, value: any) => { + debug('Updating filter: %s with value: %O', filterKey, value) + const updatedFiltersMap = { ...filtersMap, [filterKey]: getUpdatedFilter(parsedValue, value) } + setFiltersMapTrigger(updatedFiltersMap) + + // when updating URL, include params other than filters params. + const query = createQueryString(filtersOrder, updatedFiltersMap) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + } + + const initializeFilters = (initialFiltersConfig: Record<FilterKeys, InitializeFiltersConfigType<T>>) => { + debug('Adding initial filters: %O', filtersMap) + + const map = {} as Record<FilterKeys, FilterType> + const config = {} as Record<FilterKeys, FilterConfig> + + for (const key in initialFiltersConfig) { + const { defaultValue, parser, isSticky } = initialFiltersConfig[key] + const isStickyFilter = allFiltersSticky ? true : isSticky + + // If default values is set, check if it is a valid non-null value and apply filter_applied status + // If not, set the filter state to visible + const serializedDefaultValue = defaultValue ?? parser?.serialize(defaultValue) + let filterState = isStickyFilter ? FilterStatus.VISIBLE : FilterStatus.HIDDEN + + if (!isNullable(serializedDefaultValue)) { + filterState = FilterStatus.FILTER_APPLIED + } + + map[key] = { + value: defaultValue, + query: serializedDefaultValue, + state: filterState + } + + config[key] = { + defaultValue: serializedDefaultValue, + parser: initialFiltersConfig[key].parser, + isSticky: isStickyFilter + } + } + + // initialize filters + // add sticky filters + // update filters from search params; + // updating filters map state from search params + searchParams?.forEach((value, key) => { + if (map[key as FilterKeys]) { + const parser = config?.[key as FilterKeys]?.parser + const parsedValue = parser ? parser.parse(value) : value + + map[key as FilterKeys] = { + value: parsedValue, + query: value, + state: FilterStatus.FILTER_APPLIED + } + } + }) + + // setting updated filters map to state and ref + setFiltersMapTrigger(map) + setFiltersConfig(config) + + debug('Initial filters added: %O', map) + debug('Initial filters config added: %O', config) + + // setting the order of filters based on the filtersMap + // adding all the filters which are not hidden + const newFiltersOrder = Object.keys(map).filter( + filter => map[filter as FilterKeys].state !== FilterStatus.HIDDEN + ) as FilterKeys[] + setFiltersOrder(newFiltersOrder) + + const query = createQueryString(newFiltersOrder, map) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + + // remove setVisibleFilters + initialFiltersRef.current = map + } + + useEffect(() => { + if (!initialFiltersRef.current) return + + const currentQuery = createQueryString(filtersOrder, filtersMap) + const searchParamsFiltersMap = {} as Record<FilterKeys, FilterType> + + // we don't need to update URL here since it's already updated + debug('Syncing search params with filters: %s', currentQuery) + + searchParams.forEach((value, key) => { + if (filtersMap[key as FilterKeys]) { + const parser = filtersConfig?.[key as FilterKeys]?.parser + const parsedValue = parser ? parser.parse(value) : value + + searchParamsFiltersMap[key as FilterKeys] = { + value: parsedValue, + query: value, + state: FilterStatus.FILTER_APPLIED + } + } + }, {}) + + // check if filtersOrder should be passed or not + const searchParamsQuery = createQueryString(filtersOrder, searchParamsFiltersMap) + + if (currentQuery === searchParamsQuery) { + return + } + + // check typecasting + setFiltersMapTrigger(searchParamsFiltersMap as Record<FilterKeys, FilterType>) + setFiltersOrder(Object.keys(searchParamsFiltersMap) as FilterKeys[]) + }, [searchParams]) + + const createNewFilter = (): FilterType => ({ + value: undefined, + query: undefined, + state: FilterStatus.VISIBLE + }) + + const getUpdatedFilter = (parsedValue: any, value: any): FilterType => { + const isValueNullable = isNullable(parsedValue) + return { + value: value, + query: isValueNullable ? undefined : parsedValue, + state: isValueNullable ? FilterStatus.VISIBLE : FilterStatus.FILTER_APPLIED + } + } + + const getValues = (updatedFiltersMap: Record<FilterKeys, FilterType>) => { + const newFiltersMap = updatedFiltersMap || filtersMap + const filters = Object.keys(newFiltersMap).length === 0 ? initialFiltersRef.current : newFiltersMap + + return Object.entries(filters || {}).reduce((acc: T, [filterKey, value]) => { + acc[filterKey as keyof T] = value.value + return acc + }, {} as T) + } + + const resetFilters = () => { + // add only sticky filters and remove other filters. + // remove values also from sticky filters + const updatedFiltersMap = { ...filtersMap } + Object.keys(updatedFiltersMap).forEach(key => { + const isSticky = filtersConfig[key as FilterKeys]?.isSticky + const defaultValue = filtersConfig[key as FilterKeys]?.defaultValue + + const serializedDefaultValue = defaultValue + let filterState = isSticky ? FilterStatus.VISIBLE : FilterStatus.HIDDEN + + if (!isNullable(serializedDefaultValue)) { + filterState = FilterStatus.FILTER_APPLIED + } + + updatedFiltersMap[key as FilterKeys] = { + value: defaultValue, + query: serializedDefaultValue, + state: filterState + } + }) + + const newFiltersOrder = Object.keys(updatedFiltersMap).filter( + filter => updatedFiltersMap[filter as FilterKeys].state !== FilterStatus.HIDDEN + ) as FilterKeys[] + + setFiltersMapTrigger(updatedFiltersMap) + setFiltersOrder(newFiltersOrder) + + const query = createQueryString(newFiltersOrder, updatedFiltersMap) + debug('Updating URL with query: %s', query) + updateURL(new URLSearchParams(query)) + } + + useImperativeHandle(ref, () => ({ + // @ts-ignore + getValues, + reset: resetFilters + })) + + const availableFilters = Object.keys(filtersMap).filter( + filter => filtersMap[filter as FilterKeys].state === FilterStatus.HIDDEN + ) + + const getFilterValue = (filterKey: FilterKeys) => { + return filtersMap[filterKey]?.value + } + + return ( + <FiltersContext.Provider + value={ + { + visibleFilters: filtersOrder as string[], + availableFilters, + resetFilters, + removeFilter, + getFilterValue, + addFilter, + updateFilter, + addInitialFilters: initializeFilters + } as any + } + > + <div id="filters">{children}</div> + </FiltersContext.Provider> + ) +}) + +export const useFiltersContext = <T extends Record<string, unknown>>() => { + const context = useContext(FiltersContext as React.Context<FiltersContextType<T> | null>) + if (!context) { + warn('FiltersContext is missing. Ensure this is used within a FiltersProvider.') // Warn if context is missing + throw new Error('FiltersDropdown, FiltersRow, and Filter must be used within a FiltersAdapter.') + } + + return context +} + +export { Filters } + +type FiltersView = 'dropdown' | 'row' +interface FiltersWrapperProps<T extends Record<string, unknown>> extends FiltersProps<T> { + view?: FiltersView +} + +const FiltersWrapper = forwardRef(function FiltersWrapper<T extends Record<string, unknown>>( + { view = 'row', ...props }, + ref: React.Ref<FilterRefType<T>> +) { + if (view === 'row') { + return <Filters {...props} ref={ref} allFiltersSticky /> + } + + return <Filters {...props} ref={ref} /> +}) + +export default FiltersWrapper + +export const createFilters = <T extends Record<string, unknown>>() => { + const Filters = forwardRef<FilterRefType<T>, FiltersWrapperProps<T>>(function filtersCore(props, ref) { + return <FiltersWrapper ref={ref} {...props} /> + }) + + const FiltersWithStatics = Filters as typeof Filters & { + Dropdown: <K extends keyof T>(props: FiltersDropdownProps<T, K>) => JSX.Element + Content: (props: FiltersContentProps) => JSX.Element + Component: <K extends keyof T>(props: FilterProps<T, K>) => JSX.Element + } + + FiltersWithStatics.Dropdown = function filtersDropdown<K extends keyof T>(props: FiltersDropdownProps<T, K>) { + // @ts-ignore + return <FiltersDropdown {...props} /> + } + + FiltersWithStatics.Content = function filtersContent(props: FiltersContentProps) { + return <FiltersContent {...props} /> + } + + FiltersWithStatics.Component = function filtersComponent<K extends keyof T>(props: FilterProps<T, K>) { + return <Filter {...props} /> + } + + return FiltersWithStatics +} diff --git a/packages/filters/src/FiltersContent.tsx b/packages/filters/src/FiltersContent.tsx new file mode 100644 index 0000000000..4003c85afb --- /dev/null +++ b/packages/filters/src/FiltersContent.tsx @@ -0,0 +1,47 @@ +import React, { ReactNode, useRef } from 'react' + +import { useFiltersContext } from './Filters' +import { FilterStatus, InitializeFiltersConfigType } from './types' + +export interface FiltersContentProps { + children: ReactNode + className?: string +} + +const FiltersContent: React.FC<FiltersContentProps> = ({ children, className }) => { + const { visibleFilters, addInitialFilters } = useFiltersContext() + const initializedFiltersRef = useRef(false) + + const reducerInitialState = { + components: [], + filtersConfig: {} + } + + const { components, filtersConfig } = React.Children.toArray(children).reduce<{ + components: ReactNode[] + filtersConfig: Record<string, InitializeFiltersConfigType> + }>((acc, child) => { + if (React.isValidElement(child) && child.props.filterKey !== null && typeof child.props.filterKey === 'string') { + if (visibleFilters.includes(child.props.filterKey)) { + acc.components.push(child) + } + + acc.filtersConfig[child.props.filterKey] = { + defaultValue: child.props.defaultValue, + parser: child.props.parser, + isSticky: child.props.sticky, + state: FilterStatus.HIDDEN + } + } + return acc + }, reducerInitialState) + + if (initializedFiltersRef.current === false) { + addInitialFilters(filtersConfig) + initializedFiltersRef.current = true + } + + return <div className={className}>{components}</div> +} + +export default FiltersContent diff --git a/packages/filters/src/FiltersDropdown.tsx b/packages/filters/src/FiltersDropdown.tsx new file mode 100644 index 0000000000..526bd264ad --- /dev/null +++ b/packages/filters/src/FiltersDropdown.tsx @@ -0,0 +1,23 @@ +import React, { ReactNode } from 'react' + +import { useFiltersContext } from './Filters' + +export interface FiltersDropdownProps<T, K extends keyof T> { + children: (addFilter: (filterKey: K) => void, availableFilters: K[], resetFilters: () => void) => ReactNode +} + +const FiltersDropdown = <T, K extends keyof T>({ children }: FiltersDropdownProps<T, K>): React.ReactElement | null => { + const { addFilter: addFilterContext, availableFilters, resetFilters } = useFiltersContext<any>() + + const addFilter = (filterKey: K) => { + addFilterContext(filterKey) + } + + const getAvailableFilters = () => { + return availableFilters as K[] + } + + return <div id="filters-dropdown">{children(addFilter, getAvailableFilters(), resetFilters)}</div> +} + +export default FiltersDropdown diff --git a/packages/filters/src/debug.ts b/packages/filters/src/debug.ts new file mode 100644 index 0000000000..62fd54ce7f --- /dev/null +++ b/packages/filters/src/debug.ts @@ -0,0 +1,59 @@ +import { stringify } from 'flatted' // Import flatted library + +const isDebugEnabled = () => { + // Check if localStorage is available + try { + if (typeof localStorage === 'undefined') { + return false + } + const test = 'debug-test' + localStorage.setItem(test, test) + const isStorageAvailable = localStorage.getItem(test) === test + localStorage.removeItem(test) + if (!isStorageAvailable) { + return false + } + } catch (error) { + console.error('[debugUtils]: Debug mode is disabled (localStorage unavailable).', error) + return false + } + const debug = localStorage.getItem('debug') ?? '' + return debug.includes('enable-debug') // Change this as needed +} + +const enabled = isDebugEnabled() + +// Utility function to log messages with optional arguments. +export function debug(message: string, ...args: any[]) { + if (!enabled) { + return + } + const msg = sprintf(message, ...args) + performance.mark(msg) + console.log(`[DEBUG]: ${msg}`, ...args) +} + +// Utility function to log warnings. +export function warn(message: string, ...args: any[]) { + if (!enabled) { + return + } + console.warn(`[WARN]: ${message}`, ...args) +} + +// Function to format messages like `sprintf`. +export function sprintf(base: string, ...args: any[]) { + return base.replace(/%[sfdO]/g, match => { + const arg = args.shift() + if (match === '%O' && arg) { + try { + return stringify(arg) // Use `flatted.stringify` instead of `JSON.stringify` + } catch (e) { + console.error('Error stringifying object', e) + return '[Circular Object]' + } + } else { + return String(arg) + } + }) +} diff --git a/packages/filters/src/index.ts b/packages/filters/src/index.ts new file mode 100644 index 0000000000..765f999ccc --- /dev/null +++ b/packages/filters/src/index.ts @@ -0,0 +1,6 @@ +import { createFilters } from './Filters' + +export * from './parsers' +export * from './types' + +export { createFilters } diff --git a/packages/filters/src/parsers.ts b/packages/filters/src/parsers.ts new file mode 100644 index 0000000000..5112f8554f --- /dev/null +++ b/packages/filters/src/parsers.ts @@ -0,0 +1,57 @@ +import { Parser } from './types' + +export const defaultStringParser: Parser<unknown> = { + parse: (value: string) => value, + serialize: (value: unknown) => String(value) +} + +export const booleanParser: Parser<boolean> = { + parse: (value: string) => value === 'true', + serialize: (value: boolean) => (value ? 'true' : 'false') +} + +export const stringArrayParser: Parser<string[]> = { + parse: (value: string) => { + if (!value) { + return [] + } + return value.split(',').map(item => item.trim()) + }, + serialize: (value: string[]) => { + if (!Array.isArray(value)) return '' + return value.join(',') + } +} + +export const booleanArrayParser: Parser<boolean[]> = { + parse: (value: string) => { + if (!value) return [] // Return empty array if the string is empty + return value.split(',').map(item => item.trim().toLowerCase() === 'true') // Convert each value to a boolean + }, + serialize: (value: boolean[]) => { + if (!Array.isArray(value)) return '' // Return empty string if the value is not an array + return value.map(item => item.toString()).join(',') // Convert each boolean to a string and join with commas + } +} + +export const dateTimeParser: Parser<[Date, Date]> = { + parse: (value: string) => { + if (!value) { + const start = new Date() + start.setHours(0, 0, 0, 0) + + const end = new Date() + end.setHours(23, 59, 59, 999) + + return [start, end] + } + + const [startTime, endTime] = value.split(',').map(time => new Date(Number(time))) + return [startTime, endTime] + }, + + serialize: (value: [Date, Date]) => { + const [start, end] = value + return `${start.getTime()},${end.getTime()}` + } +} diff --git a/packages/filters/src/types.ts b/packages/filters/src/types.ts new file mode 100644 index 0000000000..48aaa93597 --- /dev/null +++ b/packages/filters/src/types.ts @@ -0,0 +1,34 @@ +export interface FilterType<T = any> { + value?: T + query?: string + state: FilterStatus +} + +export interface FilterConfig<T = any> { + defaultValue?: T[keyof T] + parser?: Parser<T> + isSticky?: boolean +} + +export type InitializeFiltersConfigType<T = any> = { state: FilterStatus; defaultValue?: T[keyof T] } & FilterConfig +export interface FilterTypeWithComponent<T = any> extends FilterType<T> { + component: React.ReactElement +} + +export interface FilterQueryParamsType {} + +export type Parser<T> = { + parse: (value: string) => T + serialize: (value: T) => string +} + +export enum FilterStatus { + VISIBLE = 'VISIBLE', + FILTER_APPLIED = 'FILTER_APPLIED', + HIDDEN = 'HIDDEN' +} + +export interface FilterRefType<T> { + getValues: () => T + reset: () => void +} diff --git a/packages/filters/src/useRouter.ts b/packages/filters/src/useRouter.ts new file mode 100644 index 0000000000..96a0c126d6 --- /dev/null +++ b/packages/filters/src/useRouter.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react' +// @ts-ignore +import { + createSearchParams, + // @ts-ignore + useHistory, + useLocation, + useNavigate +} from 'react-router-dom' + +interface UseRouterReturnType { + searchParams: URLSearchParams + push: (path: string, searchParams?: Record<string, string>) => void + replace: (path: string, searchParams?: Record<string, string>) => void + updateURL: (params: URLSearchParams, replace?: boolean) => void +} + +const isReactRouterV6 = typeof useNavigate === 'function' + +export default function useRouter(): UseRouterReturnType { + const navigate = isReactRouterV6 ? useNavigate() : null // v6 + const location = useLocation() // Works for both v5 and v6 + const history = !isReactRouterV6 ? useHistory() : null // v5 + + const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]) + + const push = (path: string, searchParamsObject?: Record<string, string>) => { + const search = searchParamsObject + ? `?${ + isReactRouterV6 + ? createSearchParams(searchParamsObject).toString() + : new URLSearchParams(searchParamsObject).toString() + }` + : '' + + if (isReactRouterV6 && navigate) { + navigate(`${path}${search}`, { replace: false }) + } else if (history) { + history.push(`${path}${search}`) + } + } + + const replace = (path: string, searchParamsObject?: Record<string, string>) => { + const search = searchParamsObject + ? `?${ + isReactRouterV6 + ? createSearchParams(searchParamsObject).toString() + : new URLSearchParams(searchParamsObject).toString() + }` + : '' + + if (isReactRouterV6 && navigate) { + navigate(`${path}${search}`, { replace: true }) + } else if (history) { + history.replace(`${path}${search}`) + } + } + + const updateURL = (params: URLSearchParams, replace = false) => { + const updatedSearch = `?${params.toString()}` + const path = location.pathname + + if (replace) { + if (isReactRouterV6 && navigate) { + navigate(`${path}${updatedSearch}`, { replace: true }) + } else if (history) { + history.replace(`${path}${updatedSearch}`) + } + } else { + if (isReactRouterV6 && navigate) { + navigate(`${path}${updatedSearch}`, { replace: false }) + } else if (history) { + history.push(`${path}${updatedSearch}`) + } + } + } + + return { + searchParams, + push, + replace, + updateURL + } +} diff --git a/packages/filters/src/useSearchParams.ts b/packages/filters/src/useSearchParams.ts new file mode 100644 index 0000000000..dca3110e50 --- /dev/null +++ b/packages/filters/src/useSearchParams.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' + +export default function useSearchParams() { + const [searchParams, setSearchParams] = useState(() => { + if (typeof location === 'undefined') { + return new URLSearchParams() + } + return new URLSearchParams(location.search) + }) + + useEffect(() => { + const onPopState = () => { + setSearchParams(new URLSearchParams(location.search)) + } + + window.addEventListener('popstate', onPopState) + + return () => { + window.removeEventListener('popstate', onPopState) + } + }, []) + + return { + searchParams + } +} diff --git a/packages/filters/src/utils.ts b/packages/filters/src/utils.ts new file mode 100644 index 0000000000..a11d5b01ce --- /dev/null +++ b/packages/filters/src/utils.ts @@ -0,0 +1,74 @@ +import { FilterStatus, FilterType } from './types' + +export function renderQueryString(search: URLSearchParams) { + // @ts-ignore + if (search.size === 0) { + return '' + } + const query: string[] = [] + for (const [key, value] of search.entries()) { + const safeKey = key + .replace(/#/g, '%23') + .replace(/&/g, '%26') + .replace(/\+/g, '%2B') + .replace(/=/g, '%3D') + .replace(/\?/g, '%3F') + query.push(`${safeKey}=${encodeQueryValue(value)}`) + } + const queryString = '?' + query.join('&') + return queryString +} + +export function encodeQueryValue(input: string) { + return input + .replace(/%/g, '%25') + .replace(/\+/g, '%2B') + .replace(/ /g, '+') + .replace(/#/g, '%23') + .replace(/&/g, '%26') + .replace(/"/g, '%22') + .replace(/'/g, '%27') + .replace(/`/g, '%60') + .replace(/</g, '%3C') + .replace(/>/g, '%3E') + .replace(/[\x00-\x1F]/g, char => encodeURIComponent(char)) +} + +export const createQueryString = <T extends Record<string, unknown>>( + visibleFilters: (keyof T)[], + updatedFiltersMap: Record<keyof T, FilterType> +) => { + const query = visibleFilters.reduce((acc, key) => { + if (updatedFiltersMap[key]?.state === FilterStatus.FILTER_APPLIED) { + // Add & if there's already an existing query + const stringKey = key as string + return acc + ? // @ts-ignore + `${acc}&${stringKey}=${updatedFiltersMap[stringKey].query}` + : `${stringKey}=${updatedFiltersMap[stringKey].query}` + } + return acc + }, '') as string + + return renderQueryString(new URLSearchParams(query ? `?${query}` : '')) // Add ? only if there's a query +} + +export function mergeURLSearchParams(target: URLSearchParams, source: URLSearchParams): URLSearchParams { + const mergedParams = new URLSearchParams(target.toString()) // Create a copy of target + + // Iterate through the source URLSearchParams + for (const [key, value] of source) { + // If the value is falsy except for `false`, skip the merging + if (!value && value !== 'false') { + mergedParams.delete(key) // Remove the parameter if it's falsy + } else { + mergedParams.set(key, value) // Otherwise, add or overwrite the param + } + } + + return mergedParams +} + +export function isNullable(parsedValue: string | null | undefined) { + return parsedValue === '' || parsedValue === undefined || parsedValue === null +} diff --git a/packages/filters/tsconfig.json b/packages/filters/tsconfig.json new file mode 100644 index 0000000000..1603898e23 --- /dev/null +++ b/packages/filters/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "emitDeclarationOnly": true, + "jsx": "react-jsx", + "outDir": "./dist", + "declaration": true + }, + "include": ["./src/**/*"] +} diff --git a/packages/filters/vite.config.ts b/packages/filters/vite.config.ts new file mode 100644 index 0000000000..4608d43b21 --- /dev/null +++ b/packages/filters/vite.config.ts @@ -0,0 +1,31 @@ +import { resolve } from 'path' + +import react from '@vitejs/plugin-react-swc' +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' + +const pkg = require('./package.json') + +const external = [...new Set([...Object.keys(pkg.devDependencies || []), ...Object.keys(pkg.peerDependencies || [])])] + +export default defineConfig({ + define: { 'process.env.NODE_ENV': '"production"' }, + plugins: [ + react(), + dts({ + outDir: 'dist', + tsconfigPath: './tsconfig.json' + }) + ], + build: { + sourcemap: true, + copyPublicDir: false, + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'filters', + fileName: 'index', + formats: ['es'] + }, + rollupOptions: { external } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fe57d9365..ebb84ff932 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -604,6 +604,64 @@ importers: specifier: ^4.3.0 version: 4.3.0(rollup@4.32.0)(typescript@5.7.3)(vite@6.0.11(@types/node@20.17.16)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + packages/filters: + dependencies: + react: + specifier: 17.0.2 + version: 17.0.2 + react-dom: + specifier: 17.0.2 + version: 17.0.2(react@17.0.2) + react-router-dom: + specifier: '>=5.0.0 <7.0.0' + version: 6.28.2(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + devDependencies: + '@types/node': + specifier: ^16.18.84 + version: 16.18.125 + '@types/react': + specifier: ^17.0.3 + version: 17.0.83 + '@types/react-dom': + specifier: ^17.0.3 + version: 17.0.26(@types/react@17.0.83) + '@vitejs/plugin-react-swc': + specifier: ^3.7.2 + version: 3.7.2(vite@6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + dts-bundle-generator: + specifier: ^6.4.0 + version: 6.13.0 + eslint: + specifier: ^8.57.1 + version: 8.57.1 + flatted: + specifier: ^3.3.2 + version: 3.3.2 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@16.18.125) + lint-staged: + specifier: ^15.2.9 + version: 15.4.2 + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + ts-jest: + specifier: ^29.1.2 + version: 29.2.5(@babel/core@7.26.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.7))(jest@29.7.0(@types/node@16.18.125))(typescript@5.7.3) + typescript: + specifier: ^5.3.3 + version: 5.7.3 + vite: + specifier: ^6.0.3 + version: 6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0) + vite-plugin-dts: + specifier: ^4.3.0 + version: 4.5.0(@types/node@16.18.125)(rollup@4.32.0)(typescript@5.7.3)(vite@6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + vite-plugin-svgr: + specifier: ^4.3.0 + version: 4.3.0(rollup@4.32.0)(typescript@5.7.3)(vite@6.0.11(@types/node@16.18.125)(jiti@1.21.7)(sass@1.83.4)(terser@5.37.0)(yaml@2.7.0)) + packages/forms: dependencies: '@hookform/resolvers': @@ -18278,12 +18336,12 @@ snapshots: esbuild: 0.24.2 fs-extra: 11.3.0 gulp-sort: 2.0.0 - i18next: 24.2.1(typescript@5.6.3) + i18next: 24.2.1(typescript@5.7.3) js-yaml: 4.1.0 lilconfig: 3.1.3 rsvp: 4.8.5 sort-keys: 5.1.0 - typescript: 5.6.3 + typescript: 5.7.3 vinyl: 3.0.0 vinyl-fs: 4.0.0 transitivePeerDependencies: