@activecollab/components
Version:
ActiveCollab Components
582 lines (561 loc) • 22.6 kB
JavaScript
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