Преглед на файлове

Merge branch 'master' into 0.6.0-github-org-access

Ivan Galakhov преди 4 години
родител
ревизия
c069b5d297

+ 12 - 4
dashboard/src/components/Loading.tsx

@@ -4,6 +4,8 @@ import loading from "assets/loading.gif";
 
 type PropsType = {
   offset?: string;
+  width?: string;
+  height?: string;
 };
 
 type StateType = {};
@@ -13,7 +15,11 @@ export default class Loading extends Component<PropsType, StateType> {
 
   render() {
     return (
-      <StyledLoading offset={this.props.offset}>
+      <StyledLoading
+        offset={this.props.offset}
+        width={this.props.width || "100%"}
+        height={this.props.height || "100%"}
+      >
         <Spinner src={loading} />
       </StyledLoading>
     );
@@ -24,11 +30,13 @@ const Spinner = styled.img`
   width: 20px;
 `;
 
+type StyleLoadingProps = PropsType;
+
 const StyledLoading = styled.div`
-  width: 100%;
-  height: 100%;
+  width: ${(props: StyleLoadingProps) => props.width};
+  height: ${(props: StyleLoadingProps) => props.height};
   display: flex;
   align-items: center;
   justify-content: center;
-  margin-top: ${(props: { offset?: string }) => props.offset};
+  margin-top: ${(props: StyleLoadingProps) => props.offset};
 `;

+ 233 - 239
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -1,4 +1,10 @@
-import React, { Component } from "react";
+import React, {
+  Component,
+  useState,
+  useEffect,
+  useContext,
+  useMemo,
+} from "react";
 import styled from "styled-components";
 
 import { InviteType } from "shared/types";
@@ -10,36 +16,26 @@ import InputRow from "components/values-form/InputRow";
 import Helper from "components/values-form/Helper";
 import Heading from "components/values-form/Heading";
 import CopyToClipboard from "components/CopyToClipboard";
+import { Column } from "react-table";
+import Table from "components/Table";
 
-type PropsType = {};
+type Props = {};
 
-type StateType = {
-  loading: boolean;
-  invites: InviteType[];
-  email: string;
-  invalidEmail: boolean;
-  isHTTPS: boolean;
-};
-
-const dummyInvites = [];
+const InvitePage: React.FunctionComponent<Props> = ({}) => {
+  const { currentProject } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [invites, setInvites] = useState<Array<InviteType>>([]);
+  const [email, setEmail] = useState("");
+  const [isInvalidEmail, setIsInvalidEmail] = useState(false);
+  const [isHTTPS] = useState(() => window.location.protocol === "https:");
 
-export default class InviteList extends Component<PropsType, StateType> {
-  state = {
-    loading: true,
-    invites: [] as InviteType[],
-    email: "",
-    invalidEmail: false,
-    isHTTPS: window.location.protocol === "https:",
-  };
-
-  componentDidMount() {
-    this.getInviteData();
-  }
+  useEffect(() => {
+    getInviteData();
+  }, []);
 
-  getInviteData = () => {
-    let { currentProject } = this.context;
+  const getInviteData = () => {
+    setIsLoading(true);
 
-    this.setState({ loading: true });
     api
       .getInvites(
         "<token>",
@@ -48,206 +44,220 @@ export default class InviteList extends Component<PropsType, StateType> {
           id: currentProject.id,
         }
       )
-      .then((res) => this.setState({ invites: res.data, loading: false }))
+      .then((res) => {
+        setInvites(res.data);
+        setIsLoading(false);
+      })
       .catch((err) => console.log(err));
   };
 
-  validateEmail = () => {
-    var regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    if (regex.test(this.state.email.toLowerCase())) {
-      this.setState({ invalidEmail: false });
-      this.createInvite();
-    } else {
-      this.setState({ invalidEmail: true });
-    }
-  };
-
-  createInvite = () => {
-    let { currentProject } = this.context;
+  const createInvite = () => {
     api
-      .createInvite(
-        "<token>",
-        { email: this.state.email },
-        { id: currentProject.id }
-      )
-      .then((_) => {
-        this.getInviteData();
-        this.setState({ email: "" });
+      .createInvite("<token>", { email }, { id: currentProject.id })
+      .then(() => {
+        getInviteData();
+        setEmail("");
       })
       .catch((err) => console.log(err));
   };
 
-  deleteInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const deleteInvite = (inviteId: number) => {
     api
       .deleteInvite(
         "<token>",
         {},
         {
           id: currentProject.id,
-          invId: this.state.invites[index].id,
+          invId: inviteId,
         }
       )
-      .then(this.getInviteData)
+      .then(getInviteData)
       .catch((err) => console.log(err));
   };
 
-  replaceInvite = (index: number) => {
-    let { currentProject } = this.context;
+  const replaceInvite = (inviteEmail: string, inviteId: number) => {
     api
       .createInvite(
         "<token>",
-        { email: this.state.invites[index].email },
+        { email: inviteEmail },
         { id: currentProject.id }
       )
-      .then((_) =>
+      .then(() =>
         api.deleteInvite(
           "<token>",
           {},
           {
             id: currentProject.id,
-            invId: this.state.invites[index].id,
+            invId: inviteId,
           }
         )
       )
-      .then(this.getInviteData)
+      .then(getInviteData)
       .catch((err) => console.log(err));
   };
 
-  getInviteUrl = (index: number) => {
-    let { currentProject } = this.context;
-    return `${this.state.isHTTPS ? "https://" : ""}${
-      window.location.host
-    }/api/projects/${currentProject.id}/invites/${
-      this.state.invites[index].token
-    }`;
+  const validateEmail = () => {
+    const regex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+    if (!regex.test(email.toLowerCase())) {
+      setIsInvalidEmail(true);
+      return;
+    }
+
+    setIsInvalidEmail(false);
+    createInvite();
   };
 
-  renderInvitations = () => {
-    let { currentProject } = this.context;
-    if (this.state.loading) {
-      return <Loading />;
-    } else {
-      var invContent: any[] = [];
-      var collabList: any[] = [];
-      this.state.invites.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
-      this.state.invites.sort((a: any, b: any) =>
-        a.accepted > b.accepted ? 1 : -1
-      );
-      for (let i = 0; i < this.state.invites.length; i++) {
-        if (this.state.invites[i].accepted) {
-          collabList.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}></LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton invis={true}>Remove</CopyButton>
-              </Td>
-            </Tr>
+  const columns = useMemo<
+    Column<{
+      email: string;
+      id: number;
+      status: string;
+      invite_link: string;
+    }>[]
+  >(
+    () => [
+      {
+        Header: "Mail address",
+        accessor: "email",
+      },
+      {
+        Header: "Status",
+        accessor: "status",
+        Cell: ({ row }) => {
+          return (
+            <Status status={row.values.status}>{row.values.status}</Status>
           );
-        } else if (this.state.invites[i].expired) {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  Link Expired.
-                  <NewLinkButton onClick={() => this.replaceInvite(i)}>
-                    <u>Generate a new link</u>
-                  </NewLinkButton>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        Header: "Invite link",
+        accessor: "invite_link",
+        Cell: ({ row }) => {
+          if (row.values.status === "expired") {
+            return (
+              <NewLinkButton
+                onClick={() => replaceInvite(row.values.email, row.values.id)}
+              >
+                <u>Generate a new link</u>
+              </NewLinkButton>
+            );
+          }
+          if (row.values.status === "accepted") {
+            return "";
+          }
+
+          return (
+            <>
+              <CopyToClipboard as={Url} text={row.values.invite_link}>
+                <span>{row.values.invite_link}</span>
+                <i className="material-icons-outlined">content_copy</i>
+              </CopyToClipboard>
+            </>
           );
-        } else {
-          invContent.push(
-            <Tr key={i}>
-              <MailTd isTop={i === 0}>{this.state.invites[i].email}</MailTd>
-              <LinkTd isTop={i === 0}>
-                <Rower>
-                  <ShareLink
-                    disabled={true}
-                    type="string"
-                    value={`${this.state.isHTTPS ? "https://" : ""}${
-                      window.location.host
-                    }/api/projects/${currentProject.id}/invites/${
-                      this.state.invites[i].token
-                    }`}
-                    placeholder="Unable to retrieve link"
-                  />
-                  <CopyToClipboard
-                    as={CopyButton}
-                    text={this.getInviteUrl(i)}
-                    onError={() => console.log("Couldn't copy to clipboard")}
-                  >
-                    Copy Link
-                  </CopyToClipboard>
-                </Rower>
-              </LinkTd>
-              <Td isTop={i === 0}>
-                <CopyButton onClick={() => this.deleteInvite(i)}>
-                  Delete Invite
-                </CopyButton>
-              </Td>
-            </Tr>
+        },
+      },
+      {
+        accessor: "id",
+        Cell: ({ row }) => {
+          if (row.values.status === "accepted") {
+            return <CopyButton invis={true}>Remove</CopyButton>;
+          }
+          return (
+            <>
+              <CopyButton onClick={() => deleteInvite(row.values.id)}>
+                Delete Invite
+              </CopyButton>
+            </>
           );
+        },
+      },
+    ],
+    []
+  );
+
+  const data = useMemo(() => {
+    const inviteList = [...invites];
+    inviteList.sort((a: any, b: any) => (a.email > b.email ? 1 : -1));
+    inviteList.sort((a: any, b: any) => (a.accepted > b.accepted ? 1 : -1));
+    const buildInviteLink = (token: string) => `
+      ${isHTTPS ? "https://" : ""}${window.location.host}/api/projects/${
+      currentProject.id
+    }/invites/${token}
+    `;
+
+    const mappedInviteList = inviteList.map(
+      ({ accepted, expired, token, ...rest }) => {
+        if (accepted) {
+          return {
+            status: "accepted",
+            invite_link: buildInviteLink(token),
+            ...rest,
+          };
+        }
+
+        if (!accepted && expired) {
+          return {
+            status: "expired",
+            invite_link: buildInviteLink(token),
+            ...rest,
+          };
         }
+
+        return {
+          status: "pending",
+          invite_link: buildInviteLink(token),
+          ...rest,
+        };
       }
+    );
 
-      return (
-        <>
-          <Heading>Invites & Collaborators</Heading>
-          <Helper>Manage pending invites and view collaborators.</Helper>
-          {invContent.length > 0 || collabList.length > 0 ? (
-            <Table>
-              <tbody>
-                {invContent}
-                {collabList}
-              </tbody>
-            </Table>
-          ) : (
-            <Placeholder>
-              This project currently has no invites or collaborators.
-            </Placeholder>
-          )}
-        </>
-      );
-    }
-  };
+    return mappedInviteList || [];
+  }, [invites, currentProject?.id, window?.location?.host, isHTTPS]);
 
-  render() {
-    return (
-      <>
-        <Heading isAtTop={true}>Share Project</Heading>
-        <Helper>Generate a project invite for another admin user.</Helper>
-        <DarkMatter />
+  return (
+    <>
+      <Heading isAtTop={true}>Share Project</Heading>
+      <Helper>Generate a project invite for another admin user.</Helper>
+      <InputRowWrapper>
         <InputRow
-          value={this.state.email}
+          value={email}
           type="text"
-          setValue={(x: string) => this.setState({ email: x })}
-          width="calc(100%)"
+          setValue={(newEmail: string) => setEmail(newEmail)}
+          width="100%"
           placeholder="ex: mrp@getporter.dev"
         />
-        <ButtonWrapper>
-          <InviteButton disabled={false} onClick={() => this.validateEmail()}>
-            Create Invite
-          </InviteButton>
-          {this.state.invalidEmail && (
-            <Invalid>Invalid email address. Please try again.</Invalid>
-          )}
-        </ButtonWrapper>
-        {this.renderInvitations()}
-      </>
-    );
-  }
-}
+      </InputRowWrapper>
+      <ButtonWrapper>
+        <InviteButton disabled={false} onClick={() => validateEmail()}>
+          Create Invite
+        </InviteButton>
+        {isInvalidEmail && (
+          <Invalid>Invalid email address. Please try again.</Invalid>
+        )}
+      </ButtonWrapper>
+
+      <Heading>Invites & Collaborators</Heading>
+      <Helper>Manage pending invites and view collaborators.</Helper>
+      {isLoading && <Loading height={"30%"} />}
+      {data?.length && !isLoading ? (
+        <Table
+          columns={columns}
+          data={data}
+          isLoading={false}
+          disableGlobalFilter={true}
+        />
+      ) : (
+        !isLoading && (
+          <Placeholder>
+            This project currently has no invites or collaborators.
+          </Placeholder>
+        )
+      )}
+    </>
+  );
+};
 
-InviteList.contextType = Context;
+export default InvitePage;
 
 const Placeholder = styled.div`
   width: 100%;
@@ -267,9 +277,8 @@ const ButtonWrapper = styled.div`
   align-items: center;
 `;
 
-const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -10px;
+const InputRowWrapper = styled.div`
+  width: 40%;
 `;
 
 const CopyButton = styled.div`
@@ -278,16 +287,17 @@ const CopyButton = styled.div`
   color: #ffffff;
   font-weight: 400;
   font-size: 13px;
-  margin-left: 12px;
+  margin: 8px 0 8px 12px;
   float: right;
+  display: flex;
+  justify-content: center;
+  align-items: center;
   width: 120px;
-  padding-top: 7px;
-  padding-bottom: 6px;
   cursor: pointer;
+  height: 30px;
   border-radius: 5px;
   border: 1px solid #ffffff20;
   background-color: #ffffff10;
-  text-align: center;
   overflow: hidden;
   transition: all 0.1s ease-out;
   :hover {
@@ -299,6 +309,9 @@ const CopyButton = styled.div`
 const NewLinkButton = styled(CopyButton)`
   border: none;
   width: auto;
+  float: none;
+  display: block;
+  margin: unset;
   background-color: transparent;
   :hover {
     border: none;
@@ -336,67 +349,28 @@ const InviteButton = styled.div<{ disabled: boolean }>`
   margin-bottom: 10px;
 `;
 
-const Rower = styled.div`
+const Url = styled.a`
+  max-width: 300px;
+  font-size: 13px;
+  user-select: text;
+  font-weight: 400;
   display: flex;
-  flex-direction: row;
   align-items: center;
-`;
-
-const ShareLink = styled.input`
-  outline: none;
-  border: none;
-  font-size: 13px;
-  background: none;
-  width: 60%;
-  color: #74a5f7;
-  margin-left: -10px;
-  padding: 5px 10px;
-  height: 30px;
-  text-overflow: ellipsis;
-  border-radius: 3px;
-  ::placeholder,
-  ::-webkit-input-placeholder {
-    color: #fa0a26;
-    font-weight: 600;
+  justify-content: center;
+  > i {
+    margin-left: 10px;
+    font-size: 15px;
   }
-`;
-
-const Table = styled.table`
-  width: 100%;
-  border-spacing: 0px;
-  border: 1px solid #ffffff55;
-  margin-top: 22px;
-  border-radius: 5px;
-  background: #ffffff11;
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-`;
 
-const Td = styled.td`
-  white-space: nowrap;
-  padding: 6px 0px;
-  border-top: ${(props: { isTop: boolean }) =>
-    props.isTop ? "none" : "1px solid #ffffff55"};
-  &:last-child {
-    padding-right: 16px;
+  > span {
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
   }
-`;
-
-const Tr = styled.tr``;
-
-const MailTd = styled(Td)`
-  padding: 0 12px;
-  max-width: 186px;
-  min-width: 186px;
-  overflow: hidden;
-  color: #aaaabb;
-  text-overflow: ellipsis;
-`;
 
-const LinkTd = styled(Td)`
-  width: calc(100% - 40px);
-  padding-left: 40px;
+  :hover {
+    cursor: pointer;
+  }
 `;
 
 const Invalid = styled.div`
@@ -405,3 +379,23 @@ const Invalid = styled.div`
   font-size: 13px;
   font-family: "Work Sans", sans-serif;
 `;
+
+const Status = styled.div<{ status: "accepted" | "expired" | "pending" }>`
+  padding: 5px 10px;
+  margin-right: 12px;
+  background: ${(props) => {
+    if (props.status === "accepted") return "#38a88a";
+    if (props.status === "expired") return "#cc3d42";
+    if (props.status === "pending") return "#ffffff11";
+  }};
+  font-size: 13px;
+  border-radius: 3px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 25px;
+  max-width: 80px;
+  text-transform: capitalize;
+  font-weight: 400;
+  user-select: none;
+`;

+ 2 - 2
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -3,7 +3,7 @@ import styled from "styled-components";
 
 import { Context } from "shared/Context";
 
-import InviteList from "./InviteList";
+import InvitePage from "./InviteList";
 import TabRegion from "components/TabRegion";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
@@ -33,7 +33,7 @@ export default class ProjectSettings extends Component<PropsType, StateType> {
 
   renderTabContents = () => {
     if (this.state.currentTab === "manage-access") {
-      return <InviteList />;
+      return <InvitePage />;
     } else {
       return (
         <>