diff --git a/app/client/cypress/integration/Smoke_TestSuite/OrganisationTests/CreateOrgTests_spec.js b/app/client/cypress/integration/Smoke_TestSuite/OrganisationTests/CreateOrgTests_spec.js index dc90234757..1f8c678876 100644 --- a/app/client/cypress/integration/Smoke_TestSuite/OrganisationTests/CreateOrgTests_spec.js +++ b/app/client/cypress/integration/Smoke_TestSuite/OrganisationTests/CreateOrgTests_spec.js @@ -98,11 +98,11 @@ describe("Create new org and share with a user", function() { homePage.viewerRole, ); cy.navigateToOrgSettings(orgid); - cy.get(homePage.emailList).then(function($lis) { - expect($lis).to.have.length(3); - expect($lis.eq(0)).to.contain(Cypress.env("USERNAME")); - expect($lis.eq(1)).to.contain(Cypress.env("TESTUSERNAME1")); - expect($lis.eq(2)).to.contain(Cypress.env("TESTUSERNAME2")); + cy.get(homePage.emailList).then(function($list) { + expect($list).to.have.length(3); + expect($list.eq(0)).to.contain(Cypress.env("USERNAME")); + expect($list.eq(1)).to.contain(Cypress.env("TESTUSERNAME1")); + expect($list.eq(2)).to.contain(Cypress.env("TESTUSERNAME2")); }); }); @@ -117,14 +117,15 @@ describe("Create new org and share with a user", function() { cy.get(homePage.searchInput).type(appid); cy.wait(2000); cy.navigateToOrgSettings(orgid); - cy.get(homePage.emailList).then(function($lis) { - expect($lis).to.have.length(3); - expect($lis.eq(0)).to.contain(Cypress.env("USERNAME")); - expect($lis.eq(1)).to.contain(Cypress.env("TESTUSERNAME1")); - expect($lis.eq(2)).to.contain(Cypress.env("TESTUSERNAME2")); + cy.get(homePage.emailList).then(function($list) { + expect($list).to.have.length(3); + expect($list.eq(0)).to.contain(Cypress.env("USERNAME")); + expect($list.eq(1)).to.contain(Cypress.env("TESTUSERNAME1")); + expect($list.eq(2)).to.contain(Cypress.env("TESTUSERNAME2")); }); cy.xpath(homePage.appHome) .should("be.visible") + .first() .click(); cy.wait("@applications").should( "have.nested.property", diff --git a/app/client/cypress/locators/HomePage.json b/app/client/cypress/locators/HomePage.json index 2df73d42c9..d32dcdd55a 100644 --- a/app/client/cypress/locators/HomePage.json +++ b/app/client/cypress/locators/HomePage.json @@ -1,15 +1,15 @@ { - "CreateApp":"span[class='bp3-button-text']", + "CreateApp": "span[class='bp3-button-text']", "searchInput": "input[type='text']", "appEditIcon": ".t--application-edit-link", - "publishButton":".t--application-publish-btn", - "shareButton":".t--application-share-btn", - "publishCrossButton":"span[icon='small-cross']", - "homePageID":"//div[@id='root']", - "appMoreIcon":".bp3-popover-wrapper.more .bp3-popover-target", - "deleteButton":".bp3-menu-item.bp3-popover-dismiss", - "selectAction":"#Base", - "deleteApp":".bp3-menu-item", + "publishButton": ".t--application-publish-btn", + "shareButton": ".t--application-share-btn", + "publishCrossButton": "span[icon='small-cross']", + "homePageID": "//div[@id='root']", + "appMoreIcon": ".bp3-popover-wrapper.more .bp3-popover-target", + "deleteButton": ".bp3-menu-item.bp3-popover-dismiss", + "selectAction": "#Base", + "deleteApp": ".bp3-menu-item", "homeIcon": ".t--appsmith-logo", "inputAppName": "input[name=applicationName]", "createNew": ".createnew", @@ -20,7 +20,9 @@ "members": "//div[contains(text(),'Members')]", "share": "//div[contains(text(),'Share')]", "OrgSettings": "//div[contains(text(),'Organization Settings')]", + "MemberSettings": "//div[contains(text(),'Members')]", "inviteUser": "//span[text()='Invite Users']/parent::button", + "inviteUserMembersPage": "[data-cy=t--invite-users]", "email": "//input[@type='email']", "selectRole": "//span[text()='Select a role']", "adminRole": "//div[@class='bp3-overlay bp3-overlay-open']//div[contains(text(),'Administrator')]", @@ -28,7 +30,7 @@ "developerRole": "//div[@class='bp3-overlay bp3-overlay-open']//div[contains(text(),'Developer')]", "inviteBtn": "//span[text()='Invite']/parent::button", "manageUsers": ".manageUsers", - "DeleteBtn": "//div[@data-colindex='3']//*[local-name()='svg']", + "DeleteBtn": "[data-cy=t--deleteUser]", "ShareBtn": "//span[text()='Share']/parent::button", "launchBtn": "//span[text()='Launch']/parent::button", "appView": ".t--application-view-link", @@ -40,4 +42,4 @@ "shareOrg": ") .bp3-button-text:contains('Share')", "orgSection": ".bp3-button-text:contains(", "createAppFrOrg": ") .t--create-app-popup" -} +} \ No newline at end of file diff --git a/app/client/cypress/support/commands.js b/app/client/cypress/support/commands.js index 9b4a491d2c..2f49d4a8e0 100644 --- a/app/client/cypress/support/commands.js +++ b/app/client/cypress/support/commands.js @@ -39,13 +39,14 @@ Cypress.Commands.add("navigateToOrgSettings", orgName => { .scrollIntoView() .should("be.visible"); cy.get(".t--org-name").click({ force: true }); - cy.xpath(homePage.OrgSettings).click({ force: true }); + cy.xpath(homePage.MemberSettings).click({ force: true }); + cy.wait("@getOrganisation"); cy.wait("@getRoles").should( "have.nested.property", "response.body.responseMeta.status", 200, ); - cy.xpath(homePage.inviteUser).should("be.visible"); + cy.get(homePage.inviteUserMembersPage).should("be.visible"); }); Cypress.Commands.add("inviteUserForOrg", (orgName, email, role) => { @@ -70,6 +71,7 @@ Cypress.Commands.add("inviteUserForOrg", (orgName, email, role) => { cy.contains(email); cy.get(homePage.manageUsers).click({ force: true }); cy.xpath(homePage.appHome) + .first() .should("be.visible") .click(); }); @@ -79,14 +81,16 @@ Cypress.Commands.add("deleteUserFromOrg", (orgName, email) => { .scrollIntoView() .should("be.visible"); cy.get(".t--org-name").click({ force: true }); - cy.xpath(homePage.OrgSettings).click({ force: true }); + cy.xpath(homePage.MemberSettings).click({ force: true }); + cy.wait("@getOrganisation"); cy.wait("@getRoles").should( "have.nested.property", "response.body.responseMeta.status", 200, ); - cy.xpath(homePage.DeleteBtn).click({ force: true }); + cy.get(homePage.DeleteBtn).click({ force: true }); cy.xpath(homePage.appHome) + .first() .should("be.visible") .click(); cy.wait("@applications").should( @@ -101,13 +105,13 @@ Cypress.Commands.add("updateUserRoleForOrg", (orgName, email, role) => { .scrollIntoView() .should("be.visible"); cy.get(".t--org-name").click({ force: true }); - cy.xpath(homePage.OrgSettings).click({ force: true }); + cy.xpath(homePage.MemberSettings).click({ force: true }); cy.wait("@getRoles").should( "have.nested.property", "response.body.responseMeta.status", 200, ); - cy.xpath(homePage.inviteUser).click({ force: true }); + cy.get(homePage.inviteUserMembersPage).click({ force: true }); cy.xpath(homePage.email) .click({ force: true }) .type(email); @@ -122,6 +126,7 @@ Cypress.Commands.add("updateUserRoleForOrg", (orgName, email, role) => { cy.contains(email); cy.get(".bp3-icon-small-cross").click({ force: true }); cy.xpath(homePage.appHome) + .first() .should("be.visible") .click(); cy.wait("@applications").should( @@ -1444,6 +1449,8 @@ Cypress.Commands.add("startServerAndRoutes", () => { cy.route("DELETE", "/api/v1/datasources/*").as("deleteDatasource"); cy.route("GET", "/api/v1/organizations").as("organizations"); + cy.route("GET", "/api/v1/organizations/*").as("getOrganisation"); + cy.route("POST", "/api/v1/actions/execute").as("executeAction"); cy.route("POST", "/api/v1/applications/publish/*").as("publishApp"); cy.route("PUT", "/api/v1/layouts/*/pages/*").as("updateLayout"); diff --git a/app/client/src/actions/orgActions.ts b/app/client/src/actions/orgActions.ts new file mode 100644 index 0000000000..e18d723cbe --- /dev/null +++ b/app/client/src/actions/orgActions.ts @@ -0,0 +1,68 @@ +import { ReduxActionTypes } from "constants/ReduxActionConstants"; +import { SaveOrgRequest } from "api/OrgApi"; + +export const fetchOrg = (orgId: string) => { + return { + type: ReduxActionTypes.FETCH_CURRENT_ORG, + payload: { + orgId, + }, + }; +}; + +export const changeOrgName = (name: string) => { + return { + type: ReduxActionTypes.UPDATE_ORG_NAME_INIT, + payload: { + name, + }, + }; +}; + +export const changeOrgUserRole = ( + orgId: string, + role: string, + username: string, +) => { + return { + type: ReduxActionTypes.CHANGE_ORG_USER_ROLE_INIT, + payload: { + orgId, + role, + username, + }, + }; +}; + +export const deleteOrgUser = (orgId: string, username: string) => { + return { + type: ReduxActionTypes.DELETE_ORG_USER_INIT, + payload: { + orgId, + username, + }, + }; +}; +export const fetchUsersForOrg = (orgId: string) => { + return { + type: ReduxActionTypes.FETCH_ALL_USERS_INIT, + payload: { + orgId, + }, + }; +}; +export const fetchRolesForOrg = (orgId: string) => { + return { + type: ReduxActionTypes.FETCH_ALL_ROLES_INIT, + payload: { + orgId, + }, + }; +}; + +export const saveOrg = (orgSettings: SaveOrgRequest) => { + return { + type: ReduxActionTypes.SAVE_ORG_INIT, + payload: orgSettings, + }; +}; diff --git a/app/client/src/api/OrgApi.ts b/app/client/src/api/OrgApi.ts index 8c3f4791c4..8ea68790e5 100644 --- a/app/client/src/api/OrgApi.ts +++ b/app/client/src/api/OrgApi.ts @@ -47,8 +47,9 @@ export interface FetchAllRolesRequest { export interface SaveOrgRequest { id: string; - name: string; - website: string; + name?: string; + website?: string; + email?: string; } export interface CreateOrgRequest { diff --git a/app/client/src/assets/icons/ads/upper_arrow.svg b/app/client/src/assets/icons/ads/upper_arrow.svg new file mode 100644 index 0000000000..022072bf4a --- /dev/null +++ b/app/client/src/assets/icons/ads/upper_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/client/src/components/ads/Button.tsx b/app/client/src/components/ads/Button.tsx index 715ff763b2..6c72c5bbed 100644 --- a/app/client/src/components/ads/Button.tsx +++ b/app/client/src/components/ads/Button.tsx @@ -293,7 +293,7 @@ const StyledButton = styled("button")` Button.defaultProps = { category: Category.primary, - variant: Variant.success, + variant: Variant.info, size: Size.small, isLoading: false, disabled: false, diff --git a/app/client/src/components/ads/Icon.tsx b/app/client/src/components/ads/Icon.tsx index f1585607d2..8dca9f5317 100644 --- a/app/client/src/components/ads/Icon.tsx +++ b/app/client/src/components/ads/Icon.tsx @@ -11,6 +11,8 @@ import { ReactComponent as CloseIcon } from "assets/icons/ads/close.svg"; import styled from "styled-components"; import { Size } from "./Button"; import { sizeHandler } from "./Spinner"; +import { CommonComponentProps } from "./common"; +import { noop } from "lodash"; export type IconName = | "Select icon" @@ -61,10 +63,10 @@ export type IconProps = { name?: IconName; invisible?: boolean; className?: string; - click?: () => void; + onClick?: () => void; }; -const Icon = (props: IconProps) => { +const Icon = (props: IconProps & CommonComponentProps) => { let returnIcon; switch (props.name) { case "delete": @@ -101,8 +103,9 @@ const Icon = (props: IconProps) => { return returnIcon ? ( props.click && props.click()} + onClick={props.onClick || noop} > {returnIcon} diff --git a/app/client/src/components/ads/SearchInput.tsx b/app/client/src/components/ads/SearchInput.tsx index 2500dda235..f62f8f3176 100644 --- a/app/client/src/components/ads/SearchInput.tsx +++ b/app/client/src/components/ads/SearchInput.tsx @@ -132,7 +132,7 @@ const SearchInput = forwardRef( name="close" size={Size.large} className="close-icon" - click={() => setSearchValue("")} + onClick={() => setSearchValue("")} /> ) : null} diff --git a/app/client/src/components/ads/Table.tsx b/app/client/src/components/ads/Table.tsx index ff31671d50..3a9d28345e 100644 --- a/app/client/src/components/ads/Table.tsx +++ b/app/client/src/components/ads/Table.tsx @@ -1,7 +1,8 @@ import { useTable, useSortBy } from "react-table"; import React from "react"; import styled from "styled-components"; -import { ReactComponent as DownArrow } from "assets/icons/ads/down_arrow.svg"; +import { ReactComponent as DownArrow } from "../../assets/icons/ads/down_arrow.svg"; +import { ReactComponent as UpperArrow } from "../../assets/icons/ads/upper_arrow.svg"; const Styles = styled.div` table { @@ -80,10 +81,13 @@ const Styles = styled.div` } `; -function Table(props: any) { - const data = React.useMemo(() => props.data, []); +interface TableProps { + data: any[]; + columns: any[]; +} - const columns = React.useMemo(() => props.columns, []); +function Table(props: TableProps) { + const { data, columns } = props; const { getTableProps, @@ -107,7 +111,7 @@ function Table(props: any) { {column.render("Header")} {column.isSorted ? ( column.isSortedDesc ? ( - " 🔼" + ) : ( ) @@ -126,7 +130,11 @@ function Table(props: any) { {row.cells.map((cell, index) => { return ( - + {cell.render("Cell")} ); diff --git a/app/client/src/components/ads/TableDropdown.tsx b/app/client/src/components/ads/TableDropdown.tsx index aa1ca5c975..abe4455bab 100644 --- a/app/client/src/components/ads/TableDropdown.tsx +++ b/app/client/src/components/ads/TableDropdown.tsx @@ -3,23 +3,24 @@ import { CommonComponentProps, hexToRgba } from "./common"; import { ReactComponent as DownArrow } from "assets/icons/ads/down_arrow.svg"; import Text, { TextType } from "./Text"; import styled from "styled-components"; +import { + Popover, + PopoverInteractionKind, +} from "@blueprintjs/core/lib/esm/components/popover/popover"; +import { Position } from "@blueprintjs/core/lib/esm/common/position"; type DropdownOption = { - label: string; - value: string; + name: string; + desc: string; }; type DropdownProps = CommonComponentProps & { options: DropdownOption[]; - onSelect: (selectedValue: string) => void; - selectedOption: DropdownOption; + onSelect: (selectedValue: DropdownOption) => void; + selectedIndex: number; + position?: Position; }; -const DropdownWrapper = styled.div` - width: 100%; - position: relative; -`; - const SelectedItem = styled.div` display: flex; align-items: center; @@ -32,9 +33,6 @@ const SelectedItem = styled.div` `; const OptionsWrapper = styled.div` - position: absolute; - margin-top: ${props => props.theme.spaces[8]}px; - left: -60px; width: 200px; display: flex; flex-direction: column; @@ -45,17 +43,16 @@ const OptionsWrapper = styled.div` `; const DropdownOption = styled.div<{ - selected: DropdownOption; - option: DropdownOption; + isSelected: boolean; }>` display: flex; flex-direction: column; padding: 10px 12px; cursor: pointer; - background-color: ${props => - props.option.label === props.selected.label - ? props.theme.colors.blackShades[4] - : "transparent"}; + ${props => + props.isSelected + ? `background-color: ${props.theme.colors.blackShades[4]}` + : null}; span:last-child { margin-top: ${props => props.theme.spaces[1] + 1}px; @@ -69,40 +66,44 @@ const DropdownOption = styled.div<{ `; const TableDropdown = (props: DropdownProps) => { - const [selected, setSelected] = useState(props.selectedOption); + const [selectedIndex, setSelectedIndex] = useState(props.selectedIndex); const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [selectedOption, setSelectedOption] = useState( + props.options[props.selectedIndex] || {}, + ); - const dropdownHandler = () => { - setIsDropdownOpen(!isDropdownOpen); - }; - - const optionSelector = (option: DropdownOption) => { - setSelected(option); + const optionSelector = (index: number) => { + setSelectedIndex(index); + setSelectedOption(props.options[index]); + props.onSelect && props.onSelect(props.options[index]); setIsDropdownOpen(false); }; return ( - - dropdownHandler()}> - {selected.label} + setIsDropdownOpen(state)} + interactionKind={PopoverInteractionKind.CLICK} + > + + {selectedOption.name} - {isDropdownOpen ? ( - - {props.options.map((el: DropdownOption, index: number) => ( - optionSelector(el)} - > - {el.label} - {el.value} - - ))} - - ) : null} - + + {props.options.map((el: DropdownOption, index: number) => ( + optionSelector(index)} + > + {el.name} + {el.desc} + + ))} + + ); }; diff --git a/app/client/src/components/ads/Tabs.tsx b/app/client/src/components/ads/Tabs.tsx index 8bf7f809e3..f1a5211b00 100644 --- a/app/client/src/components/ads/Tabs.tsx +++ b/app/client/src/components/ads/Tabs.tsx @@ -5,6 +5,13 @@ import styled from "styled-components"; import Icon, { IconName } from "./Icon"; import { Size } from "./Button"; +export type TabProp = { + key: string; + title: string; + panelComponent: JSX.Element; + icon: IconName; +}; + const TabsWrapper = styled.div<{ shouldOverflow?: boolean }>` user-select: none; border-radius: 0px; @@ -59,14 +66,6 @@ const TabsWrapper = styled.div<{ shouldOverflow?: boolean }>` fill: ${props => props.theme.colors.blackShades[9]}; } } - .react-tabs__tab:focus { - box-shadow: none; - border-bottom: ${props => props.theme.colors.info.main} - ${props => props.theme.spaces[1] - 2}px solid; - path { - fill: ${props => props.theme.colors.blackShades[9]}; - } - } .react-tabs__tab--selected { color: ${props => props.theme.colors.blackShades[9]}; background-color: transparent; @@ -85,10 +84,21 @@ const TabsWrapper = styled.div<{ shouldOverflow?: boolean }>` background-color: ${props => props.theme.colors.info.main}; } } - .react-tabs__tab:focus:after { - content: none; - height: ${props => props.theme.spaces[1] - 2}px; - background: ${props => props.theme.colors.info.main}; + .react-tabs__tab:focus { + &::after { + content: ""; + position: absolute; + width: 100%; + bottom: ${props => props.theme.spaces[0] - 1}px; + left: ${props => props.theme.spaces[0]}px; + height: ${props => props.theme.spaces[1] - 2}px; + background-color: ${props => props.theme.colors.info.main}; + } + box-shadow: none; + border-color: transparent; + path { + fill: ${props => props.theme.colors.blackShades[9]}; + } } `; @@ -100,14 +110,9 @@ const TabTitle = styled.span` `; type TabbedViewComponentType = { - tabs: Array<{ - key: string; - title: string; - panelComponent: JSX.Element; - icon?: IconName; - }>; + tabs: Array; selectedIndex?: number; - setSelectedIndex?: Function; + onSelect?: Function; overflow?: boolean; }; @@ -117,7 +122,7 @@ export const TabComponent = (props: TabbedViewComponentType) => { { - props.setSelectedIndex && props.setSelectedIndex(index); + props.onSelect && props.onSelect(index); }} > diff --git a/app/client/src/components/ads/TextInput.tsx b/app/client/src/components/ads/TextInput.tsx index 05a531336a..f7549f4d07 100644 --- a/app/client/src/components/ads/TextInput.tsx +++ b/app/client/src/components/ads/TextInput.tsx @@ -3,6 +3,34 @@ import { CommonComponentProps, hexToRgba } from "./common"; import styled from "styled-components"; import Text, { TextType } from "./Text"; import { theme } from "constants/DefaultTheme"; +import { + FORM_VALIDATION_INVALID_EMAIL, + ERROR_MESSAGE_NAME_EMPTY, +} from "constants/messages"; +import { isEmail } from "utils/formhelpers"; + +export type Validator = ( + value: string, +) => { + isValid: boolean; + message: string; +}; + +export function emailValidator(email: string) { + const isValid = isEmail(email); + return { + isValid: isValid, + message: !isValid ? FORM_VALIDATION_INVALID_EMAIL : "", + }; +} + +export function notEmptyValidator(value: string) { + const isValid = !!value; + return { + isValid: isValid, + message: !isValid ? ERROR_MESSAGE_NAME_EMPTY : "", + }; +} export type TextInputProps = CommonComponentProps & { placeholder?: string; @@ -43,7 +71,6 @@ const StyledInput = styled.input< border-radius: 0; outline: 0; box-shadow: none; - margin-bottom: ${props => props.theme.spaces[1]}px; border: 1px solid ${props => props.inputStyle.borderColor}; padding: ${props => props.theme.spaces[4]}px ${props => props.theme.spaces[6]}px; @@ -73,12 +100,17 @@ const InputWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-start; + position: relative; span { color: ${props => props.theme.colors.danger.main}; } `; +const ErrorWrapper = styled.div` + position absolute; + bottom: -17px; +`; const TextInput = forwardRef( (props: TextInputProps, ref: Ref) => { const initialValidation = () => { @@ -101,13 +133,26 @@ const TextInput = forwardRef( const memoizedChangeHandler = useCallback( el => { - props.validator && setValidation(props.validator(el.target.value)); - return props.onChange && props.onChange(el.target.value); + const validation = props.validator && props.validator(el.target.value); + if (validation) { + props.validator && setValidation(validation); + return ( + validation.isValid && + props.onChange && + props.onChange(el.target.value) + ); + } else { + return props.onChange && props.onChange(el.target.value); + } }, [props], ); - const ErrorMessage = {validation.message}; + const ErrorMessage = ( + + {validation.message} + + ); return ( @@ -118,19 +163,15 @@ const TextInput = forwardRef( isValid={validation.isValid} defaultValue={props.defaultValue} {...props} - placeholder={props.placeholder ? props.placeholder : ""} + placeholder={props.placeholder} onChange={memoizedChangeHandler} /> - {validation.isValid ? null : ErrorMessage} + {ErrorMessage} ); }, ); -TextInput.defaultProps = { - fill: false, -}; - TextInput.displayName = "TextInput"; export default TextInput; diff --git a/app/client/src/components/stories/Icon.stories.tsx b/app/client/src/components/stories/Icon.stories.tsx index be68e7b7cd..e64661e4dd 100644 --- a/app/client/src/components/stories/Icon.stories.tsx +++ b/app/client/src/components/stories/Icon.stories.tsx @@ -1,8 +1,8 @@ import React from "react"; +import Icon from "components/ads/Icon"; import Button, { Size, Category, Variant } from "components/ads/Button"; import { withKnobs, select, boolean } from "@storybook/addon-knobs"; import { withDesign } from "storybook-addon-designs"; -import Icon from "components/ads/Icon"; import AppIcon, { AppIconName } from "components/ads/AppIcon"; import { StoryWrapper } from "./Tabs.stories"; diff --git a/app/client/src/components/stories/Table.stories.tsx b/app/client/src/components/stories/Table.stories.tsx index f1b64772f1..2dd563f8b2 100644 --- a/app/client/src/components/stories/Table.stories.tsx +++ b/app/client/src/components/stories/Table.stories.tsx @@ -2,8 +2,10 @@ import React from "react"; import Table from "components/ads/Table"; import Button, { Category, Variant, Size } from "components/ads/Button"; import Icon from "components/ads/Icon"; +import TableDropdown from "components/ads/TableDropdown"; +import { Position } from "@blueprintjs/core/lib/esm/common/position"; import { StoryWrapper } from "./Tabs.stories"; - + export default { title: "Table", component: Table, @@ -36,11 +38,33 @@ const columns = [ }, ]; +const options = [ + { + name: "Admin", + desc: "Can edit, view and invite other user to an app", + }, + { + name: "Developer", + desc: "Can view and invite other user to an app", + }, + { + name: "User", + desc: "Can view and invite other user to an app and...", + }, +]; + const data = [ { col1: "Dustin Howard", col2: "dustin_01@jlegue.com", - col3: "Developer", + col3: ( + console.log(selectedValue)} + selectedIndex={0} + > + ), col4: "App Access", col5: ( + } + canOutsideClickClose={true} + Form={OrgInviteUsersForm} + orgId={orgId} + title={`Invite Users to ${currentOrgName}`} + /> + + {isFetchingAllUsers && isFetchingAllRoles ? ( + + ) : ( +
+ )} + + ); +} diff --git a/app/client/src/pages/organization/OrgInviteUsersForm.tsx b/app/client/src/pages/organization/OrgInviteUsersForm.tsx index 3abda4d23d..2b0ccbddb3 100644 --- a/app/client/src/pages/organization/OrgInviteUsersForm.tsx +++ b/app/client/src/pages/organization/OrgInviteUsersForm.tsx @@ -302,7 +302,7 @@ const OrgInviteUsersForm = (props: any) => { filled intent="primary" onClick={() => { - history.push(`/org/${props.orgId}/settings`); + history.push(`/org/${props.orgId}/settings/members`); }} /> )} diff --git a/app/client/src/pages/organization/index.tsx b/app/client/src/pages/organization/index.tsx index 14bd8171fa..282a054dea 100644 --- a/app/client/src/pages/organization/index.tsx +++ b/app/client/src/pages/organization/index.tsx @@ -1,9 +1,9 @@ import React from "react"; import { Switch, useRouteMatch, useLocation } from "react-router-dom"; import PageWrapper from "pages/common/PageWrapper"; -import Settings from "./settings"; import DefaultOrgPage from "./defaultOrgPage"; import AppRoute from "pages/common/AppRoute"; +import Settings from "./settings"; export const Organization = () => { const { path } = useRouteMatch(); const location = useLocation(); @@ -11,7 +11,6 @@ export const Organization = () => { void; - fetchCurrentOrg: (orgId: string) => void; - fetchUser: (orgId: string) => void; - fetchAllRoles: (orgId: string) => void; - deleteOrgUser: (orgId: string, username: string) => void; - changeOrgUserRole: (orgId: string, role: string, username: string) => void; - allUsers: OrgUser[]; - allRole: object; - currentUser: User | undefined; - isFetchAllUsers: boolean; - isFetchAllRoles: boolean; -}; +import MemberSettings from "./Members"; +import IconComponent from "components/designSystems/appsmith/IconComponent"; +import { fetchOrg } from "actions/orgActions"; +import { GeneralSettings } from "./General"; -export type PageProps = OrgProps & - RouteComponentProps<{ - orgId: string; - }>; - -export type MenuItemProps = { - rolename: string; -}; - -type DropdownProps = { - activeItem: string; - userRoles: object; - username: string; - changeOrgUserRole: (orgId: string, role: string, username: string) => void; - orgId: string; -}; - -const StyledDropDown = styled.div` - cursor: pointer; -`; - -const StyledTableWrapped = styled(TableWrapper)` - height: ${props => props.height}px; - overflow: visible; - .tableWrap { - height: ${props => props.height}px; +const LinkToApplications = styled(Link)` + margin-bottom: 35px; + width: auto; + &:hover { + text-decoration: none; } - .table { - .tbody { - height: ${props => props.height}px; - } - } -`; - -const StyledMenu = styled(Menu)` - &&&&.bp3-menu { - max-width: 250px; + svg { cursor: pointer; } `; -const RoleNameCell = (props: any) => { - const { - roleName, - roles, - username, - isCurrentUser, - isChangingRole, - } = props.cellProps.row.original; - - if (isCurrentUser) { - return
{roleName}
; - } - - return ( - - } - position={Position.BOTTOM_LEFT} - > - - {roleName} - - {isChangingRole ? : undefined} - - - ); -}; - -const DeleteActionCell = (props: any) => { - const { username, isCurrentUser, isDeleting } = props.cellProps.row.original; - - return ( - !isCurrentUser && - (isDeleting ? ( - - ) : ( - props.deleteOrgUser(props.orgId, username)} - style={{ alignSelf: "center", cursor: "pointer" }} - /> - )) - ); -}; - -const Dropdown = (props: DropdownProps) => { - return ( - - {Object.entries(props.userRoles).map((role, index) => { - const MenuContent = ( -
- - {role[0]} - -
{role[1]}
-
- ); - - return ( - - props.changeOrgUserRole(props.orgId, role[0], props.username) - } - active={props.activeItem === role[0]} - text={MenuContent} - /> - ); - })} -
- ); -}; - -export const OrgSettings = (props: PageProps) => { - const { - match: { - params: { orgId }, - }, - deleteOrgUser, - changeOrgUserRole, - fetchCurrentOrg, - fetchUser, - fetchAllRoles, - currentOrg, - } = props; - - const userTableData = props.allUsers.map(user => ({ - ...user, - roles: props.allRole, - isCurrentUser: user.username === props.currentUser?.username, - })); - const data = React.useMemo(() => userTableData, [userTableData]); - - const tableHeight = React.useMemo(() => { - const tableDataLength = - userTableData.length * TABLE_SIZES[CompactModeTypes.DEFAULT].ROW_HEIGHT + - TABLE_SIZES[CompactModeTypes.DEFAULT].COLUMN_HEADER_HEIGHT; - return tableDataLength; - }, [userTableData]); - - const columns = React.useMemo(() => { - return [ - { - Header: "Email", - accessor: "username", - }, - { - Header: "Name", - accessor: "name", - }, - { - Header: "Role", - accessor: "roleName", - Cell: (cellProps: any) => { - return RoleNameCell({ cellProps, changeOrgUserRole, orgId }); - }, - }, - { - Header: "Delete", - accessor: "delete", - Cell: (cellProps: any) => { - return DeleteActionCell({ cellProps, deleteOrgUser, orgId }); - }, - }, - ]; - }, [orgId, deleteOrgUser, changeOrgUserRole]); - - const currentOrgName = currentOrg?.name ?? ""; - const { - getTableProps, - getTableBodyProps, - headerGroups, - rows, - prepareRow, - } = useTable( - { - columns, - data, - manualPagination: true, - }, - useFlexLayout, - ); - +export default function Settings() { + const { orgId } = useParams(); + const currentOrg = useSelector(getCurrentOrg); + const { path } = useRouteMatch(); + const location = useLocation(); + const dispatch = useDispatch(); useEffect(() => { - fetchUser(orgId); - fetchAllRoles(orgId); - fetchCurrentOrg(orgId); - }, [orgId, fetchUser, fetchAllRoles, fetchCurrentOrg]); - return ( - - -

{currentOrgName}

-
- - -

Users

- - } - canOutsideClickClose={true} - Form={OrgInviteUsersForm} - orgId={orgId} - title={`Invite Users to ${currentOrgName}`} - /> -
- {props.isFetchAllUsers && props.isFetchAllRoles ? ( - - ) : ( - -
-
- {headerGroups.map((headerGroup: any, index: number) => ( -
- {headerGroup.headers.map( - (column: any, columnIndex: number) => ( -
-
- {column.render("Header")} -
-
- ), - )} -
- ))} -
- {rows.map((row: any, index: number) => { - prepareRow(row); - return ( -
- {row.cells.map((cell: any, cellIndex: number) => { - return ( -
- {cell.render("Cell")} -
- ); - })} -
- ); - })} -
-
-
-
- )} -
+ dispatch(fetchOrg(orgId as string)); + }, []); + + const SettingsRenderer = ( +
+ + +
); -}; -const mapStateToProps = (state: AppState) => ({ - allUsers: getAllUsers(state), - allRole: getAllRoles(state), - isFetchAllUsers: state.ui.orgs.loadingStates.isFetchAllUsers, - isFetchAllRoles: state.ui.orgs.loadingStates.isFetchAllRoles, - currentOrg: getCurrentOrg(state), - currentUser: getCurrentUser(state), -}); + const tabArr: TabProp[] = [ + { + key: "general", + title: "General", + panelComponent: SettingsRenderer, + icon: "general", + }, + { + key: "members", + title: "Members", + panelComponent: SettingsRenderer, + icon: "user", + }, + ]; + const isMembersPage = location.pathname.indexOf("members") !== -1; -const mapDispatchToProps = (dispatch: any) => ({ - fetchCurrentOrg: (orgId: string) => - dispatch({ - type: ReduxActionTypes.FETCH_CURRENT_ORG, - payload: { - orgId, - }, - }), - changeOrgName: (name: string) => - dispatch({ - type: ReduxActionTypes.UPDATE_ORG_NAME_INIT, - payload: { - name, - }, - }), - changeOrgUserRole: (orgId: string, role: string, username: string) => - dispatch({ - type: ReduxActionTypes.CHANGE_ORG_USER_ROLE_INIT, - payload: { - orgId, - role, - username, - }, - }), - deleteOrgUser: (orgId: string, username: string) => - dispatch({ - type: ReduxActionTypes.DELETE_ORG_USER_INIT, - payload: { - orgId, - username, - }, - }), - fetchUser: (orgId: string) => - dispatch({ - type: ReduxActionTypes.FETCH_ALL_USERS_INIT, - payload: { - orgId, - }, - }), - fetchAllRoles: (orgId: string) => - dispatch({ - type: ReduxActionTypes.FETCH_ALL_ROLES_INIT, - payload: { - orgId, - }, - }), -}); + return ( + <> + + + {currentOrg.name} + + { + const settingsStartIndex = location.pathname.indexOf("settings"); + const settingsEndIndex = settingsStartIndex + "settings".length; + const hasSlash = location.pathname[settingsEndIndex] === "/"; + let newUrl = ""; -export default connect(mapStateToProps, mapDispatchToProps)(OrgSettings); + if (hasSlash) { + newUrl = `${location.pathname.substr(0, settingsEndIndex)}/${ + tabArr[index].key + }`; + } else { + newUrl = `${location.pathname}/${tabArr[index].key}`; + } + history.push(newUrl); + }} + > + + ); +} diff --git a/app/client/src/reducers/uiReducers/orgReducer.ts b/app/client/src/reducers/uiReducers/orgReducer.ts index 1b832b5104..890e4cae1e 100644 --- a/app/client/src/reducers/uiReducers/orgReducer.ts +++ b/app/client/src/reducers/uiReducers/orgReducer.ts @@ -1,4 +1,4 @@ -import { createReducer } from "utils/AppsmithUtils"; +import { createImmerReducer } from "utils/AppsmithUtils"; import { ReduxAction, ReduxActionTypes, @@ -11,6 +11,7 @@ const initialState: OrgReduxState = { fetchingRoles: false, isFetchAllRoles: false, isFetchAllUsers: false, + isFetchingOrg: false, }, orgUsers: [], orgRoles: [], @@ -20,161 +21,121 @@ const initialState: OrgReduxState = { }, }; -const orgReducer = createReducer(initialState, { - [ReduxActionTypes.FETCH_ORG_ROLES_INIT]: (state: OrgReduxState) => ({ - ...state, - loadingStates: { - ...state.loadingStates, - fetchingRoles: true, - }, - }), - [ReduxActionTypes.FETCH_ALL_ROLES_INIT]: (state: OrgReduxState) => ({ - ...state, - loadingStates: { - ...state.loadingStates, - isFetchAllRoles: true, - }, - }), - [ReduxActionTypes.FETCH_ALL_USERS_INIT]: (state: OrgReduxState) => ({ - ...state, - loadingStates: { - ...state.loadingStates, - isFetchAllUsers: true, - }, - }), - +const orgReducer = createImmerReducer(initialState, { + [ReduxActionTypes.FETCH_ORG_ROLES_INIT]: (draftState: OrgReduxState) => { + draftState.loadingStates.isFetchAllRoles = true; + }, + [ReduxActionTypes.FETCH_ALL_ROLES_INIT]: (draftState: OrgReduxState) => { + draftState.loadingStates.isFetchAllRoles = true; + }, + [ReduxActionTypes.FETCH_ALL_USERS_INIT]: (draftState: OrgReduxState) => { + draftState.loadingStates.isFetchAllUsers = true; + }, [ReduxActionTypes.FETCH_ORG_ROLES_SUCCESS]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction, - ) => ({ - ...state, - roles: action.payload, - loadingStates: { - ...state.loadingStates, - fetchingRoles: false, - }, - }), - [ReduxActionErrorTypes.FETCH_ORG_ROLES_ERROR]: (state: OrgReduxState) => ({ - ...state, - loadingStates: { - ...state.loadingStates, - fetchingRoles: false, - }, - }), + ) => { + draftState.orgRoles = action.payload; + draftState.loadingStates.fetchingRoles = false; + }, + [ReduxActionErrorTypes.FETCH_ORG_ROLES_ERROR]: ( + draftState: OrgReduxState, + ) => { + draftState.loadingStates.fetchingRoles = false; + }, [ReduxActionTypes.FETCH_ALL_USERS_SUCCESS]: ( - state: OrgReduxState, - action: ReduxAction, - ) => ({ - ...state, - orgUsers: action.payload, - loadingStates: { - ...state.loadingStates, - isFetchAllUsers: false, - }, - }), + draftState: OrgReduxState, + action: ReduxAction, + ) => { + draftState.orgUsers = action.payload; + draftState.loadingStates.isFetchAllUsers = false; + }, [ReduxActionTypes.FETCH_ALL_ROLES_SUCCESS]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction, - ) => ({ - ...state, - orgRoles: action.payload, - loadingStates: { - ...state.loadingStates, - isFetchAllRoles: false, - }, - }), + ) => { + draftState.orgRoles = action.payload; + draftState.loadingStates.isFetchAllRoles = false; + }, [ReduxActionTypes.CHANGE_ORG_USER_ROLE_SUCCESS]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction<{ username: string; roleName: string }>, ) => { - const _orgUsers = state.orgUsers.map((user: OrgUser) => { + draftState.orgUsers.forEach((user: OrgUser) => { if (user.username === action.payload.username) { - return { - ...user, - roleName: action.payload.roleName, - isChangingRole: false, - }; + user.roleName = action.payload.roleName; } - return user; }); - return { - ...state, - orgUsers: _orgUsers, - }; }, [ReduxActionTypes.CHANGE_ORG_USER_ROLE_INIT]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction<{ username: string }>, ) => { - const _orgUsers = state.orgUsers.map((user: OrgUser) => { + draftState.orgUsers.forEach((user: OrgUser) => { if (user.username === action.payload.username) { - return { - ...user, - isChangingRole: true, - }; + user.isChangingRole = true; } - return user; }); - return { ...state, orgUsers: _orgUsers }; }, [ReduxActionTypes.DELETE_ORG_USER_INIT]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction<{ username: string }>, ) => { - const _orgUsers = state.orgUsers.map((user: OrgUser) => { + draftState.orgUsers.forEach((user: OrgUser) => { if (user.username === action.payload.username) { - return { - ...user, - isDeleting: true, - }; + user.isDeleting = true; } - return user; }); - return { ...state, orgUsers: _orgUsers }; }, [ReduxActionTypes.DELETE_ORG_USER_SUCCESS]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction<{ username: string }>, ) => { - const _orgUsers = state.orgUsers.filter( + draftState.orgUsers = draftState.orgUsers.filter( (user: OrgUser) => user.username !== action.payload.username, ); - return { - ...state, - orgUsers: _orgUsers, - }; }, - [ReduxActionTypes.CHANGE_ORG_USER_ROLE_ERROR]: (state: OrgReduxState) => { - const _orgUsers = state.orgUsers.map(user => ({ - ...user, - isChangingRole: false, - })); - return { ...state, orgUsers: _orgUsers }; + [ReduxActionErrorTypes.CHANGE_ORG_USER_ROLE_ERROR]: ( + draftState: OrgReduxState, + ) => { + draftState.orgUsers.forEach((user: OrgUser) => { + //TODO: This will change the status to false even if one role change api fails. + user.isChangingRole = false; + }); }, - [ReduxActionTypes.DELETE_ORG_USER_ERROR]: (state: OrgReduxState) => { - const _orgUsers = state.orgUsers.map(user => ({ - ...user, - isDeleting: false, - })); - return { ...state, orgUsers: _orgUsers }; + [ReduxActionErrorTypes.DELETE_ORG_USER_ERROR]: ( + draftState: OrgReduxState, + ) => { + draftState.orgUsers.forEach((user: OrgUser) => { + //TODO: This will change the status to false even if one delete fails. + user.isDeleting = false; + }); }, [ReduxActionTypes.SET_CURRENT_ORG_ID]: ( - state: OrgReduxState, + draftState: OrgReduxState, action: ReduxAction<{ orgId: string }>, - ) => ({ - ...state, - currentOrg: { - ...state.currentOrg, - id: action.payload.orgId, - }, - }), - [ReduxActionTypes.FETCH_ORG_SUCCESS]: ( - state: OrgReduxState, + ) => { + draftState.currentOrg.id = action.payload.orgId; + }, + [ReduxActionTypes.SET_CURRENT_ORG]: ( + draftState: OrgReduxState, action: ReduxAction, - ) => ({ - ...state, - currentOrg: action.payload, - }), + ) => { + draftState.currentOrg = action.payload; + }, + [ReduxActionTypes.FETCH_CURRENT_ORG]: (draftState: OrgReduxState) => { + draftState.loadingStates.isFetchingOrg = true; + }, + [ReduxActionTypes.FETCH_ORG_SUCCESS]: ( + draftState: OrgReduxState, + action: ReduxAction, + ) => { + draftState.currentOrg = action.payload; + draftState.loadingStates.isFetchingOrg = false; + }, + [ReduxActionErrorTypes.FETCH_ORG_ERROR]: (draftState: OrgReduxState) => { + draftState.loadingStates.isFetchingOrg = false; + }, }); export interface OrgReduxState { @@ -184,6 +145,7 @@ export interface OrgReduxState { fetchingRoles: boolean; isFetchAllRoles: boolean; isFetchAllUsers: boolean; + isFetchingOrg: boolean; }; orgUsers: OrgUser[]; orgRoles: any; diff --git a/app/client/src/sagas/OrgSagas.ts b/app/client/src/sagas/OrgSagas.ts index 4fec6ed57c..d503d39567 100644 --- a/app/client/src/sagas/OrgSagas.ts +++ b/app/client/src/sagas/OrgSagas.ts @@ -1,4 +1,4 @@ -import { call, takeLatest, put, all } from "redux-saga/effects"; +import { call, takeLatest, put, all, select } from "redux-saga/effects"; import { ReduxActionTypes, ReduxAction, @@ -26,6 +26,7 @@ import OrgApi, { import { ApiResponse } from "api/ApiResponses"; import { AppToaster } from "components/editorComponents/ToastComponent"; import { ToastType } from "react-toastify"; +import { getCurrentOrg } from "selectors/organizationSelectors"; export function* fetchRolesSaga() { try { @@ -114,6 +115,9 @@ export function* changeOrgUserRoleSaga( } catch (error) { yield put({ type: ReduxActionErrorTypes.CHANGE_ORG_USER_ROLE_ERROR, + payload: { + error, + }, }); } } @@ -172,6 +176,17 @@ export function* saveOrgSaga(action: ReduxAction) { const response: ApiResponse = yield call(OrgApi.saveOrg, request); const isValidResponse = yield validateResponse(response); if (isValidResponse) { + const currentOrg = yield select(getCurrentOrg); + if (currentOrg && currentOrg.id === request.id) { + const updatedOrg = { + ...currentOrg, + ...request, + }; + yield put({ + type: ReduxActionTypes.SET_CURRENT_ORG, + payload: updatedOrg, + }); + } yield put({ type: ReduxActionTypes.SAVE_ORG_SUCCESS, }); diff --git a/app/client/src/selectors/organizationSelectors.tsx b/app/client/src/selectors/organizationSelectors.tsx index 45d00c51e8..f027668244 100644 --- a/app/client/src/selectors/organizationSelectors.tsx +++ b/app/client/src/selectors/organizationSelectors.tsx @@ -6,6 +6,14 @@ export const getRolesFromState = (state: AppState) => { return state.ui.orgs.roles; }; +export const getOrgLoadingStates = (state: AppState) => { + return { + isFetchingOrg: state.ui.orgs.loadingStates.isFetchingOrg, + isFetchingAllUsers: state.ui.orgs.loadingStates.isFetchAllUsers, + isFetchingAllRoles: state.ui.orgs.loadingStates.isFetchAllRoles, + }; +}; + export const getCurrentOrgId = (state: AppState) => state.ui.orgs.currentOrg.id; export const getOrgs = (state: AppState) => { return state.ui.applications.userOrgs; diff --git a/app/client/src/utils/AppsmithUtils.tsx b/app/client/src/utils/AppsmithUtils.tsx index 2acf54b047..2e2f49c7ce 100644 --- a/app/client/src/utils/AppsmithUtils.tsx +++ b/app/client/src/utils/AppsmithUtils.tsx @@ -10,6 +10,7 @@ import * as log from "loglevel"; import { LogLevelDesc } from "loglevel"; import FeatureFlag from "utils/featureFlags"; import { appCardColors } from "constants/AppConstants"; +import produce from "immer"; export const createReducer = ( initialState: any, @@ -24,6 +25,19 @@ export const createReducer = ( }; }; +export const createImmerReducer = ( + initialState: any, + handlers: { [type: string]: any }, +) => { + return function reducer(state = initialState, action: ReduxAction) { + if (handlers.hasOwnProperty(action.type)) { + return produce(handlers[action.type])(state, action); + } else { + return state; + } + }; +}; + export const appInitializer = () => { FormControlRegistry.registerFormControlBuilders(); const appsmithConfigs = getAppsmithConfigs();