UNPKG

@activecollab/components

Version:

ActiveCollab Components

582 lines (561 loc) 22.6 kB
import React, { useCallback, useEffect, useRef, useState } from "react"; import styled, { createGlobalStyle } from "styled-components"; import { CONNECTABLE_REPOSITORIES, CONNECTIONS, connectionIcon, ConnectRepositoryDialog, PROJECT_REPOSITORIES, RepositorySelect } from "./ConnectRepositoryDialog"; import { Button } from "../../components/Button"; import { ChooseV2 } from "../../components/ChooseV2"; import { Dialog } from "../../components/Dialog"; import { Input } from "../../components/Input"; import { Label } from "../../components/Label"; import { SkeletonLoader } from "../../components/Loaders"; import { Select } from "../../components/Select"; import { SelectTrigger } from "../../components/SelectTrigger"; import { Caption1 } from "../../components/Typography"; /** * "Link Branch or Pull Request" dialog — shared by every `Presentation/Task * Source` story (the empty state and the populated Source Code section) so the * flow lives in one place. * * Three modes (ChooseV2): **Link Existing** (default), **New Branch**, **New * Pull Request**. The primary button is always enabled; validation runs on * click and outlines the offending field. Esc does not close the dialog (it * would lose entered data) — this is an action dialog. * * On open the body shows a ~1s skeleton, standing in for the future API loads * that will drive the repository / branch / PR pickers. * * The **Link Existing** picker is a grouped, multi-level select: options are * grouped by repository (with the connection name in parentheses), each option * carries a branch or pull-request icon, everything is sorted by name and * filterable via the search box at the top. The menu is sized to match the * trigger width. */ const SANS = '-apple-system, BlinkMacSystemFont, "Roboto", "Helvetica Neue", Arial, sans-serif'; /* ---- Mock data ------------------------------------------------------- */ const LINKABLES = [{ id: "pr-500", kind: "pr", // Deliberately long, to show the name truncating with an ellipsis while the // right-aligned detail and radio stay visible. name: "#500 Refactor rank recalculation to support multi-region contract archival and backfill", repoId: "backend", updatedMinutesAgo: 8, author: "Nikola M." }, { id: "branch-cache", kind: "branch", name: "spike/rank-cache", repoId: "backend", updatedMinutesAgo: 30 }, { id: "branch-export", kind: "branch", name: "feature/retainer-export-34", repoId: "backend", updatedMinutesAgo: 5 * 60 }, { id: "pr-119900", kind: "pr", name: "#119900 Rank API endpoints", repoId: "backend", updatedMinutesAgo: 2 * 24 * 60, author: "Ilija S." }, { id: "branch-fe-retainers", kind: "branch", name: "feature/retainers-ui-34", repoId: "frontend", updatedMinutesAgo: 2 * 60 }, { id: "pr-412", kind: "pr", name: "#412 Contract list filters", repoId: "frontend", updatedMinutesAgo: 24 * 60, author: "Marko K." }, { id: "mr-88", kind: "pr", name: "!88 Bump worker memory", repoId: "infra", updatedMinutesAgo: 3 * 24 * 60, author: "Ana P." }, { id: "branch-deploy", kind: "branch", name: "chore/deploy-rank-cache", repoId: "infra", updatedMinutesAgo: 24 * 60 }]; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const pad = n => String(n).padStart(2, "0"); /** * Reproduces the format of ActiveCollab's standard `Ago` component * (`Angie.functions.ago()`) for the mock: "Just now", "N minutes ago", * "Today HH:mm", "Yesterday HH:mm", "MMM D. HH:mm", else "MMM D. YYYY HH:mm". * Production must use the real component. */ const formatAgo = minutesAgo => { const now = new Date(); const then = new Date(now.getTime() - minutesAgo * 60000); if (minutesAgo < 2) { return "Just now"; } if (minutesAgo < 60) { return Math.floor(minutesAgo) + " minutes ago"; } const time = pad(then.getHours()) + ":" + pad(then.getMinutes()); if (then.toDateString() === now.toDateString()) { return "Today " + time; } const yesterday = new Date(now.getTime() - 24 * 60 * 60000); if (then.toDateString() === yesterday.toDateString()) { return "Yesterday " + time; } const monthDay = MONTHS[then.getMonth()] + " " + then.getDate() + "."; if (then.getFullYear() === now.getFullYear()) { return monthDay + " " + time; } return monthDay + " " + then.getFullYear() + " " + time; }; /** * The detail shown on the right of an option: a branch keeps just the time of * its last activity; a pull request appends the author ("… by Name"). */ const linkableDetail = item => item.kind === "pr" && item.author ? formatAgo(item.updatedMinutesAgo) + " by " + item.author : formatAgo(item.updatedMinutesAgo); /** * Grouped options for the Select. One group per repository, labelled * "repo (Org)" and sorted by name; inside a group, branches and PRs are * ordered by latest activity (freshest first). Each option carries a leading * provider icon (GitHub / GitLab / Git) via the Select's `icon` slot; the * service type is dropped from the group header since the icon now conveys it. */ const GROUPED_OPTIONS = PROJECT_REPOSITORIES.map(repo => { const connection = CONNECTIONS.find(c => c.id === repo.connectionId); const icon = connectionIcon(connection == null ? void 0 : connection.service); const items = LINKABLES.filter(l => l.repoId === repo.id).sort((a, b) => a.updatedMinutesAgo - b.updatedMinutesAgo).map(l => ({ id: l.id, name: l.name, additionalInfo: linkableDetail(l), icon })); return { id: repo.id, name: connection ? repo.name + " (" + connection.name + ")" : repo.name, options: items }; }).filter(group => group.options.length > 0).sort((a, b) => a.name.localeCompare(b.name)); const ALL_ITEMS = LINKABLES; /* ---- Branch mock data (for the New Branch / New Pull Request bases) --- */ const BRANCHES = [ // activecollab/backend { id: "backend-main", name: "main", repoId: "backend", isDefault: true, updatedMinutesAgo: 60 }, { id: "backend-cache", name: "spike/rank-cache", repoId: "backend", updatedMinutesAgo: 30 }, { id: "backend-export", name: "feature/retainer-export-34", repoId: "backend", updatedMinutesAgo: 5 * 60 }, { id: "backend-hotfix", name: "hotfix/rank-typo", repoId: "backend", updatedMinutesAgo: 3 * 24 * 60 }, // activecollab/frontend { id: "frontend-main", name: "main", repoId: "frontend", isDefault: true, updatedMinutesAgo: 90 }, { id: "frontend-retainers", name: "feature/retainers-ui-34", repoId: "frontend", updatedMinutesAgo: 2 * 60 }, { id: "frontend-filters", name: "feature/contract-filters", repoId: "frontend", updatedMinutesAgo: 24 * 60 }, // activecollab/infrastructure { id: "infra-master", name: "master", repoId: "infra", isDefault: true, updatedMinutesAgo: 6 * 60 }, { id: "infra-deploy", name: "chore/deploy-rank-cache", repoId: "infra", updatedMinutesAgo: 24 * 60 }]; const repoBranches = repoId => BRANCHES.filter(b => b.repoId === repoId); const repoDefaultBranchId = repoId => { var _repoBranches$find$id, _repoBranches$find; return (_repoBranches$find$id = (_repoBranches$find = repoBranches(repoId).find(b => b.isDefault)) == null ? void 0 : _repoBranches$find.id) != null ? _repoBranches$find$id : null; }; /** * Base-branch options (Create From / Merge Into): the default branch first with * no timestamp, then the rest sorted by latest activity (freshest first), each * with a muted "last activity" time. */ const baseBranchOptions = repoId => { const branches = repoBranches(repoId); const defaults = branches.filter(b => b.isDefault).map(b => ({ id: b.id, name: b.name })); const rest = branches.filter(b => !b.isDefault).sort((a, b) => a.updatedMinutesAgo - b.updatedMinutesAgo).map(b => ({ id: b.id, name: b.name, additionalInfo: formatAgo(b.updatedMinutesAgo) })); return [...defaults, ...rest]; }; /** * Source-branch options (New Pull Request): the default branch is excluded — you * don't open a PR to merge the trunk into a feature branch — and the rest are * sorted by latest activity, each with a muted "last activity" time. */ const sourceBranchOptions = repoId => repoBranches(repoId).filter(b => !b.isDefault).sort((a, b) => a.updatedMinutesAgo - b.updatedMinutesAgo).map(b => ({ id: b.id, name: b.name, additionalInfo: formatAgo(b.updatedMinutesAgo) })); /* ---- Styles ---------------------------------------------------------- */ const StyledLinkBody = styled.div.withConfig({ displayName: "LinkBranchOrPullRequestDialog__StyledLinkBody", componentId: "sc-2wiwoe-0" })(["font-family:", ";.lk-tabbody{margin-top:18px;}.lk-field{margin-bottom:16px;}.lk-field:last-child{margin-bottom:0;}.lk-label{display:block;margin-bottom:6px;}.lk-control,.lk-control.c-input-wrapper{width:100%;max-width:none;}.lk-repo-hint{display:block;margin-top:8px;}.lk-repo-link{background:none;border:none;padding:0;font:inherit;color:var(--color-primary);cursor:pointer;}.lk-repo-link:hover{text-decoration:underline;}.lk-skeleton .sk-row{margin-bottom:18px;}.lk-skeleton .sk-choose{width:100%;height:36px;border-radius:6px;}.lk-skeleton .sk-label{width:160px;height:12px;margin-bottom:8px;}.lk-skeleton .sk-control{width:100%;height:40px;border-radius:6px;}"], SANS); /** * The grouped select menu renders through a portal, so the "match the trigger * width" override has to be global, scoped to the menu's class. `$menuWidth` is * measured from the trigger and injected here. */ const MenuGlobalStyle = createGlobalStyle([".c-select.lk-existing-select-menu{", "}.c-select.lk-existing-select-menu .c-option--text + span{color:var(--color-theme-600);font-size:12px;}"], _ref => { let $menuWidth = _ref.$menuWidth; return $menuWidth ? "width: " + $menuWidth + "px !important; max-width: none !important;" : ""; }); /** * Per-instance global style for a branch select menu: matches the trigger width * and applies the standard muted styling to the right-aligned "last activity". */ const BranchMenuGlobalStyle = createGlobalStyle([".c-select.", "{", "}.c-select.", " .c-option--text + span{color:var(--color-theme-600);font-size:12px;}"], p => p.$cls, p => p.$w ? "width: " + p.$w + "px !important; max-width: none !important;" : "", p => p.$cls); const BranchSelectWrap = styled.div.withConfig({ displayName: "LinkBranchOrPullRequestDialog__BranchSelectWrap", componentId: "sc-2wiwoe-1" })(["font-family:", ";.branch-trigger{width:100%;max-width:none;}"], SANS); /** * Flat branch picker (Create From / Source Branch / Merge Into). The caller * supplies the option order (default-first or activity-sorted); the DS internal * alphabetical sort is disabled so that order is preserved. */ const BranchSelect = _ref2 => { var _options$find$name, _options$find; let options = _ref2.options, selected = _ref2.selected, onChange = _ref2.onChange, _ref2$placeholder = _ref2.placeholder, placeholder = _ref2$placeholder === void 0 ? "Select branch" : _ref2$placeholder; const wrapRef = useRef(null); const _useState = useState(), menuWidth = _useState[0], setMenuWidth = _useState[1]; const _useState2 = useState(() => "branch-select-menu-" + Math.random().toString(36).slice(2)), menuCls = _useState2[0]; const measure = useCallback(() => { if (wrapRef.current) { setMenuWidth(wrapRef.current.offsetWidth); } }, []); useEffect(() => { measure(); }, [measure]); const selectedName = (_options$find$name = (_options$find = options.find(o => o.id === selected)) == null ? void 0 : _options$find.name) != null ? _options$find$name : placeholder; return /*#__PURE__*/React.createElement(BranchSelectWrap, { ref: wrapRef }, /*#__PURE__*/React.createElement(BranchMenuGlobalStyle, { $cls: menuCls, $w: menuWidth }), /*#__PURE__*/React.createElement(Select, { options: options, selected: selected === null ? undefined : selected, onChange: v => onChange(String(v)), placeholder: "Search branches", selectClassName: menuCls, onSelectOpen: measure, disabledInternalSort: true, forceCloseMenu: true, target: /*#__PURE__*/React.createElement(SelectTrigger, { className: "branch-trigger", typographyProps: selected === null ? { color: "tertiary" } : undefined }, selectedName) })); }; const MODE_OPTIONS = [{ id: "existing", name: "Link Existing" }, { id: "branch", name: "New Branch" }, { id: "pr", name: "New Pull Request" }]; export const LinkBranchOrPullRequestDialog = _ref3 => { var _ALL_ITEMS$find$name, _ALL_ITEMS$find; let open = _ref3.open, onClose = _ref3.onClose; const _useState3 = useState("existing"), mode = _useState3[0], setMode = _useState3[1]; const _useState4 = useState(null), selected = _useState4[0], setSelected = _useState4[1]; const _useState5 = useState("feature/retainers-34"), branchName = _useState5[0], setBranchName = _useState5[1]; const _useState6 = useState("#34 Retainers & Contracts functionality"), prTitle = _useState6[0], setPrTitle = _useState6[1]; const _useState7 = useState(false), attempted = _useState7[0], setAttempted = _useState7[1]; const _useState8 = useState(true), loading = _useState8[0], setLoading = _useState8[1]; const _useState9 = useState(), menuWidth = _useState9[0], setMenuWidth = _useState9[1]; // Repository picker (New Branch / New Pull Request) + connect-repository flow. const _useState0 = useState("backend"), repo = _useState0[0], setRepo = _useState0[1]; const _useState1 = useState(false), connectOpen = _useState1[0], setConnectOpen = _useState1[1]; const _useState10 = useState([]), extraRepos = _useState10[0], setExtraRepos = _useState10[1]; // Branch bases. Create From / Merge Into default to the repo's default branch; // Source Branch starts unselected. const _useState11 = useState(() => repoDefaultBranchId("backend")), createFrom = _useState11[0], setCreateFrom = _useState11[1]; const _useState12 = useState(null), sourceBranch = _useState12[0], setSourceBranch = _useState12[1]; const _useState13 = useState(() => repoDefaultBranchId("backend")), mergeInto = _useState13[0], setMergeInto = _useState13[1]; // When the repository changes, reset the bases: default branch for // Create From / Merge Into, nothing for Source Branch. useEffect(() => { const defaultId = repoDefaultBranchId(repo); setCreateFrom(defaultId); setMergeInto(defaultId); setSourceBranch(null); }, [repo]); const fieldRef = useRef(null); // Simulate the API loads that will drive the pickers: a ~1s skeleton on open. useEffect(() => { if (!open) { return undefined; } setLoading(true); const timer = window.setTimeout(() => setLoading(false), 1000); return () => window.clearTimeout(timer); }, [open]); const measureMenu = useCallback(() => { if (fieldRef.current) { setMenuWidth(fieldRef.current.offsetWidth); } }, []); // Keep the menu width in sync with the trigger once the form is visible. useEffect(() => { if (open && !loading && mode === "existing") { measureMenu(); } }, [open, loading, mode, measureMenu]); // Submit is always enabled; validation runs on click and outlines fields. const existingInvalid = attempted && mode === "existing" && selected === null; const branchInvalid = attempted && mode === "branch" && branchName.trim() === ""; const titleInvalid = attempted && mode === "pr" && prTitle.trim() === ""; const primaryLabel = mode === "existing" ? "Link" : mode === "branch" ? "Create Branch" : "Create Pull Request"; const close = () => { setAttempted(false); onClose(); }; const handleSubmit = () => { const valid = mode === "existing" ? selected !== null : mode === "branch" ? branchName.trim() !== "" : prTitle.trim() !== ""; if (!valid) { setAttempted(true); return; } close(); }; const changeMode = next => { setMode(next); setAttempted(false); }; const selectedLabel = (_ALL_ITEMS$find$name = (_ALL_ITEMS$find = ALL_ITEMS.find(o => o.id === selected)) == null ? void 0 : _ALL_ITEMS$find.name) != null ? _ALL_ITEMS$find$name : "Select branch or pull request"; // Repositories already on the project (plus any just connected), and the // remaining ones that can still be connected from here. const projectRepos = [...PROJECT_REPOSITORIES, ...extraRepos]; const connectableRepos = CONNECTABLE_REPOSITORIES.filter(r => !projectRepos.some(p => p.id === r.id)); const handleConnected = connected => { setExtraRepos(prev => prev.some(r => r.id === connected.id) ? prev : [...prev, connected]); // The repository just connected becomes the selection in the picker. setRepo(connected.id); }; const renderRepositoryField = () => /*#__PURE__*/React.createElement("div", { className: "lk-field" }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label" }, "Repository"), /*#__PURE__*/React.createElement(RepositorySelect, { repositories: projectRepos, connections: CONNECTIONS, selected: repo, onChange: setRepo }), /*#__PURE__*/React.createElement(Caption1, { color: "tertiary", className: "lk-repo-hint" }, "Only repositories connected to this project are listed.", " ", /*#__PURE__*/React.createElement("button", { type: "button", className: "lk-repo-link", onClick: () => setConnectOpen(true) }, "Connect a repository"), " ", "if the one you need is missing.")); return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(Dialog, { open: open, onClose: close, disableCloseOnEsc: true }, /*#__PURE__*/React.createElement(MenuGlobalStyle, { $menuWidth: menuWidth }), /*#__PURE__*/React.createElement(Dialog.Title, null, "Link Branch or Pull Request"), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Content, null, /*#__PURE__*/React.createElement(StyledLinkBody, null, loading ? /*#__PURE__*/React.createElement("div", { className: "lk-skeleton" }, /*#__PURE__*/React.createElement("div", { className: "sk-row" }, /*#__PURE__*/React.createElement(SkeletonLoader, { className: "sk-choose" })), /*#__PURE__*/React.createElement("div", { className: "sk-row" }, /*#__PURE__*/React.createElement(SkeletonLoader, { className: "sk-label" }), /*#__PURE__*/React.createElement(SkeletonLoader, { className: "sk-control" }))) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement(ChooseV2, { required: true, options: MODE_OPTIONS, selected: [mode], onChange: ids => changeMode(ids[0]) }), /*#__PURE__*/React.createElement("div", { className: "lk-tabbody" }, mode === "existing" ? /*#__PURE__*/React.createElement("div", { className: "lk-field", ref: fieldRef }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label", invalid: existingInvalid }, "Branch or Pull Request"), /*#__PURE__*/React.createElement(Select, { options: GROUPED_OPTIONS, selected: selected === null ? undefined : selected, onChange: v => setSelected(v), placeholder: "Search branches and pull requests", selectClassName: "lk-existing-select-menu", onSelectOpen: measureMenu, disabledInternalSort: true, forceCloseMenu: true, target: /*#__PURE__*/React.createElement(SelectTrigger, { className: "lk-control", invalid: existingInvalid, typographyProps: selected === null ? { color: "tertiary" } : undefined }, selectedLabel) })) : mode === "branch" ? /*#__PURE__*/React.createElement(React.Fragment, null, renderRepositoryField(), /*#__PURE__*/React.createElement("div", { className: "lk-field" }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label", invalid: branchInvalid }, "Branch Name"), /*#__PURE__*/React.createElement(Input, { className: "lk-control", value: branchName, invalid: branchInvalid, onChange: e => setBranchName(e.target.value) })), /*#__PURE__*/React.createElement("div", { className: "lk-field" }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label" }, "Create From"), /*#__PURE__*/React.createElement(BranchSelect, { options: baseBranchOptions(repo), selected: createFrom, onChange: setCreateFrom, placeholder: "Select base branch" }))) : /*#__PURE__*/React.createElement(React.Fragment, null, renderRepositoryField(), /*#__PURE__*/React.createElement("div", { className: "lk-field" }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label" }, "Source Branch"), /*#__PURE__*/React.createElement(BranchSelect, { options: sourceBranchOptions(repo), selected: sourceBranch, onChange: setSourceBranch, placeholder: "Select source branch" })), /*#__PURE__*/React.createElement("div", { className: "lk-field" }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label" }, "Merge Into"), /*#__PURE__*/React.createElement(BranchSelect, { options: baseBranchOptions(repo), selected: mergeInto, onChange: setMergeInto, placeholder: "Select base branch" })), /*#__PURE__*/React.createElement("div", { className: "lk-field" }, /*#__PURE__*/React.createElement(Label, { size: "small", className: "lk-label", invalid: titleInvalid }, "Title"), /*#__PURE__*/React.createElement(Input, { className: "lk-control", value: prTitle, invalid: titleInvalid, onChange: e => setPrTitle(e.target.value) }))))))), /*#__PURE__*/React.createElement(Dialog.ContentDivider, null), /*#__PURE__*/React.createElement(Dialog.Actions, null, /*#__PURE__*/React.createElement(Button, { variant: "primary", style: { marginRight: 12 }, disabled: loading, onClick: handleSubmit }, primaryLabel), /*#__PURE__*/React.createElement(Button, { variant: "secondary", onClick: close }, "Cancel"))), /*#__PURE__*/React.createElement(ConnectRepositoryDialog, { open: connectOpen, onClose: () => setConnectOpen(false), onConnect: handleConnected, repositories: connectableRepos, connections: CONNECTIONS })); }; //# sourceMappingURL=LinkBranchOrPullRequestDialog.js.map