diff --git a/packages/app/src/app/pages/NewDashboard/Sidebar/TeamAvatar.tsx b/packages/app/src/app/components/TeamAvatar/TeamAvatar.tsx similarity index 73% rename from packages/app/src/app/pages/NewDashboard/Sidebar/TeamAvatar.tsx rename to packages/app/src/app/components/TeamAvatar/TeamAvatar.tsx index 71c75c44d28..ab4fa59ff79 100644 --- a/packages/app/src/app/pages/NewDashboard/Sidebar/TeamAvatar.tsx +++ b/packages/app/src/app/components/TeamAvatar/TeamAvatar.tsx @@ -13,7 +13,17 @@ export const backgrounds = [ 'blues.700', ]; -export const TeamAvatar = ({ name, size = 'big', ...props }) => { +interface TeamAvatarProps { + name: string; + size?: 'small' | 'big'; + className?: string; +} + +export const TeamAvatar = ({ + name, + size = 'big', + className, +}: TeamAvatarProps) => { if (!name) return null; // consistent color @@ -30,8 +40,10 @@ export const TeamAvatar = ({ name, size = 'big', ...props }) => { textTransform: 'uppercase', backgroundColor, color: 'white', + fontWeight: 600, + fontFamily: 'Inter', })} - {...props} + className={className} > {name[0]} diff --git a/packages/app/src/app/components/TeamAvatar/index.ts b/packages/app/src/app/components/TeamAvatar/index.ts new file mode 100644 index 00000000000..8b7ed0b81eb --- /dev/null +++ b/packages/app/src/app/components/TeamAvatar/index.ts @@ -0,0 +1 @@ +export { TeamAvatar } from './TeamAvatar'; diff --git a/packages/app/src/app/graphql/introspection-result.ts b/packages/app/src/app/graphql/introspection-result.ts index aa8b944f6ca..6281c7307dd 100644 --- a/packages/app/src/app/graphql/introspection-result.ts +++ b/packages/app/src/app/graphql/introspection-result.ts @@ -9,7 +9,6 @@ export interface IntrospectionResultData { }[]; }; } - const result: IntrospectionResultData = { __schema: { types: [ @@ -37,5 +36,4 @@ const result: IntrospectionResultData = { ], }, }; - export default result; diff --git a/packages/app/src/app/graphql/types.ts b/packages/app/src/app/graphql/types.ts index 560e5c8c64b..044139eb64a 100644 --- a/packages/app/src/app/graphql/types.ts +++ b/packages/app/src/app/graphql/types.ts @@ -1168,6 +1168,18 @@ export type TeamFragmentDashboardFragment = { __typename?: 'Team' } & Pick< >; }; +export type CurrentTeamInfoFragmentFragment = { __typename?: 'Team' } & Pick< + Team, + 'id' | 'creatorId' | 'description' | 'inviteToken' | 'name' +> & { + users: Array< + { __typename?: 'User' } & Pick< + User, + 'avatarUrl' | 'name' | 'lastName' | 'username' | 'id' + > + >; + }; + export type _CreateTeamMutationVariables = Exact<{ name: Scalars['String']; }>; @@ -1531,19 +1543,7 @@ export type GetTeamQueryVariables = Exact<{ export type GetTeamQuery = { __typename?: 'RootQueryType' } & { me: Maybe< { __typename?: 'CurrentUser' } & { - team: Maybe< - { __typename?: 'Team' } & Pick< - Team, - 'id' | 'creatorId' | 'description' | 'inviteToken' | 'name' - > & { - users: Array< - { __typename?: 'User' } & Pick< - User, - 'avatarUrl' | 'name' | 'lastName' | 'username' | 'id' - > - >; - } - >; + team: Maybe<{ __typename?: 'Team' } & CurrentTeamInfoFragmentFragment>; } >; }; diff --git a/packages/app/src/app/overmind/actions.ts b/packages/app/src/app/overmind/actions.ts index 181889e9c5a..8561a90c737 100755 --- a/packages/app/src/app/overmind/actions.ts +++ b/packages/app/src/app/overmind/actions.ts @@ -290,3 +290,17 @@ export const rejectTeamInvitation: Action<{ teamName: string }> = ( effects.notificationToast.success(`Rejected invitation to ${teamName}`); }; + +export const getActiveTeam: AsyncAction = async ({ state, effects }) => { + if (!state.activeTeam) return; + + const team = await effects.gql.queries.getTeam({ + teamId: state.activeTeam, + }); + + if (!team || !team.me) { + return; + } + + state.activeTeamInfo = team.me.team; +}; diff --git a/packages/app/src/app/overmind/effects/gql/dashboard/fragments.ts b/packages/app/src/app/overmind/effects/gql/dashboard/fragments.ts index 50ca534c4fb..7f1e8e34bbf 100644 --- a/packages/app/src/app/overmind/effects/gql/dashboard/fragments.ts +++ b/packages/app/src/app/overmind/effects/gql/dashboard/fragments.ts @@ -113,3 +113,20 @@ export const teamFragmentDashboard = gql` } } `; + +export const currentTeamInfoFragment = gql` + fragment currentTeamInfoFragment on Team { + id + creatorId + description + inviteToken + name + users { + avatarUrl + name + lastName + username + id + } + } +`; diff --git a/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts b/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts index 0c7ae296110..049b9a7fc5c 100644 --- a/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts +++ b/packages/app/src/app/overmind/effects/gql/dashboard/queries.ts @@ -26,6 +26,7 @@ import { sandboxFragmentDashboard, sidebarCollectionDashboard, templateFragmentDashboard, + currentTeamInfoFragment, } from './fragments'; export const deletedSandboxes: Query< @@ -202,19 +203,9 @@ export const getTeam: Query = gql` query getTeam($teamId: ID!) { me { team(id: $teamId) { - id - creatorId - description - inviteToken - name - users { - avatarUrl - name - lastName - username - id - } + ...currentTeamInfoFragment } } } + ${currentTeamInfoFragment} `; diff --git a/packages/app/src/app/overmind/factories.ts b/packages/app/src/app/overmind/factories.ts index adffb8fe1fa..d24d2d098f4 100755 --- a/packages/app/src/app/overmind/factories.ts +++ b/packages/app/src/app/overmind/factories.ts @@ -66,16 +66,21 @@ export const withLoadApp = ( if (state.hasLogIn) { try { - state.user = await effects.api.getCurrentUser(); - actions.internal.setPatronPrice(); - effects.analytics.identify('signed_in', true); - effects.analytics.setUserId(state.user.id, state.user.email); const localStorageTeam = effects.browser.storage.get( TEAM_ID_LOCAL_STORAGE ); if (localStorageTeam) { - state.dashboard.activeTeam = localStorageTeam; + state.activeTeam = localStorageTeam; } + const [user] = await Promise.all([ + effects.api.getCurrentUser(), + actions.getActiveTeam(), + ]); + state.user = user; + actions.internal.setPatronPrice(); + effects.analytics.identify('signed_in', true); + effects.analytics.setUserId(state.user.id, state.user.email); + try { actions.internal.trackCurrentTeams().catch(e => {}); actions.internal.identifyCurrentUser().catch(e => {}); diff --git a/packages/app/src/app/overmind/namespaces/dashboard/actions.ts b/packages/app/src/app/overmind/namespaces/dashboard/actions.ts index da2e7db19e1..ec70a433a1e 100755 --- a/packages/app/src/app/overmind/namespaces/dashboard/actions.ts +++ b/packages/app/src/app/overmind/namespaces/dashboard/actions.ts @@ -27,9 +27,9 @@ export const setActiveTeam: Action<{ id: string | null; }> = ({ state, effects }, { id }) => { // ignore if its already selected - if (id === state.dashboard.activeTeam) return; + if (id === state.activeTeam) return; - state.dashboard.activeTeam = id; + state.activeTeam = id; effects.browser.storage.set(TEAM_ID_LOCAL_STORAGE, id); state.dashboard.sandboxes = { ...state.dashboard.sandboxes, @@ -139,35 +139,20 @@ export const getTeams: AsyncAction = async ({ state, effects }) => { state.dashboard.teams = teams.me.teams; }; -export const getTeam: AsyncAction = async ({ state, effects }) => { - if (!state.dashboard.activeTeam) return; - const team = await effects.gql.queries.getTeam({ - teamId: state.dashboard.activeTeam, - }); - - if (!team || !team.me) { - return; - } - - state.dashboard.activeTeamInfo = team.me.team; -}; - export const removeFromTeam: AsyncAction = async ( { state, effects }, id ) => { - if (!state.dashboard.activeTeam || !state.dashboard.activeTeamInfo) return; + if (!state.activeTeam || !state.activeTeamInfo) return; try { await effects.gql.mutations.removeFromTeam({ - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, userId: id, }); - state.dashboard.activeTeamInfo = { - ...state.dashboard.activeTeamInfo, - users: (state.dashboard.activeTeamInfo.users || []).filter( - user => user.id !== id - ), + state.activeTeamInfo = { + ...state.activeTeamInfo, + users: (state.activeTeamInfo.users || []).filter(user => user.id !== id), }; } catch { effects.notificationToast.error( @@ -177,17 +162,17 @@ export const removeFromTeam: AsyncAction = async ( }; export const leaveTeam: AsyncAction = async ({ state, effects, actions }) => { - if (!state.dashboard.activeTeam || !state.dashboard.activeTeamInfo) return; + if (!state.activeTeam || !state.activeTeamInfo) return; try { await effects.gql.mutations.leaveTeam({ - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, }); actions.dashboard.setActiveTeam({ id: null }); actions.dashboard.getTeams(); effects.notificationToast.success( - `You successfully left the ${state.dashboard.activeTeamInfo.name} team` + `You successfully left the ${state.activeTeamInfo.name} team` ); } catch (e) { effects.notificationToast.error( @@ -200,20 +185,20 @@ export const inviteToTeam: AsyncAction = async ( { state, effects }, value ) => { - if (!state.dashboard.activeTeam) return; + if (!state.activeTeam) return; const isEmail = value.includes('@'); try { let data: any = null; if (isEmail) { const emailInvited = await effects.gql.mutations.inviteToTeamVieEmail({ - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, email: value, }); data = emailInvited.inviteToTeamViaEmail; } else { const usernameInvited = await effects.gql.mutations.inviteToTeam({ - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, username: value, }); @@ -253,8 +238,7 @@ export const getRecentSandboxes: AsyncAction = async ({ state, effects }) => { dashboard.sandboxes[sandboxesTypes.RECENT] = data.me.sandboxes .filter( sandbox => - (sandbox.collection || { collection: {} }).teamId === - state.dashboard.activeTeam + (sandbox.collection || { collection: {} }).teamId === state.activeTeam ) .slice(0, 50); } catch (error) { @@ -267,7 +251,7 @@ export const getRecentSandboxes: AsyncAction = async ({ state, effects }) => { export const getAllFolders: AsyncAction = async ({ state, effects }) => { try { const data = await effects.gql.queries.getCollections({ - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, }); if (!data || !data.me || !data.me.collections) { return; @@ -358,7 +342,7 @@ export const getDrafts: AsyncAction = async ({ state, effects }) => { try { const data = await effects.gql.queries.sandboxesByPath({ path: '/', - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, }); if (!data || !data.me || !data.me.collection) { return; @@ -383,7 +367,7 @@ export const getSandboxesByPath: AsyncAction = async ( try { const data = await effects.gql.queries.sandboxesByPath({ path: '/' + path, - teamId: state.dashboard.activeTeam, + teamId: state.activeTeam, }); if (!data || !data.me || !data.me.collection) { return; @@ -421,10 +405,10 @@ export const getDeletedSandboxes: AsyncAction = async ({ state, effects }) => { export const getTemplateSandboxes: AsyncAction = async ({ state, effects }) => { const { dashboard } = state; try { - if (dashboard.activeTeam) { + if (state.activeTeam) { dashboard.sandboxes[sandboxesTypes.TEMPLATES] = null; const data = await effects.gql.queries.teamTemplates({ - id: dashboard.activeTeam, + id: state.activeTeam, }); if (!data || !data.me || !data.me.team) { @@ -866,8 +850,7 @@ export const getSearchSandboxes: AsyncAction = async ( .filter(x => !x.customTemplate) .filter( sandbox => - (sandbox.collection || { collection: {} }).teamId === - state.dashboard.activeTeam + (sandbox.collection || { collection: {} }).teamId === state.activeTeam ); dashboard.sandboxes[sandboxesTypes.SEARCH] = sandboxesToShow; @@ -955,7 +938,7 @@ export const setTeamInfo: AsyncAction<{ description: string | null; }> = async ({ effects, state }, { name, description }) => { const oldTeamInfo = state.dashboard.teams.find(team => team.name === name); - const oldActiveTeamInfo = state.dashboard.activeTeamInfo; + const oldActiveTeamInfo = state.activeTeamInfo; try { await effects.gql.mutations.setTeamName({ name, @@ -984,13 +967,17 @@ export const setTeamInfo: AsyncAction<{ return team; }); - state.dashboard.activeTeamInfo = { - ...oldActiveTeamInfo, - name, - description, - }; + if (oldActiveTeamInfo) { + state.activeTeamInfo = { + ...oldActiveTeamInfo, + name, + description, + }; + } } catch (e) { - state.dashboard.activeTeamInfo = { ...oldActiveTeamInfo }; + if (oldActiveTeamInfo) { + state.activeTeamInfo = { ...oldActiveTeamInfo }; + } if (state.dashboard.teams && oldTeamInfo) { state.dashboard.teams = [...state.dashboard.teams, oldTeamInfo]; } diff --git a/packages/app/src/app/overmind/namespaces/dashboard/state.ts b/packages/app/src/app/overmind/namespaces/dashboard/state.ts index 96264ed8941..60b39fdd939 100755 --- a/packages/app/src/app/overmind/namespaces/dashboard/state.ts +++ b/packages/app/src/app/overmind/namespaces/dashboard/state.ts @@ -49,8 +49,6 @@ type State = { }; teams: Array<{ __typename?: 'Team' } & Pick>; allCollections: DELETE_ME_COLLECTION[] | null; - activeTeam: string | null; - activeTeamInfo: any | null; selectedSandboxes: string[]; trashSandboxIds: string[]; isDragging: boolean; @@ -89,8 +87,6 @@ export const state: State = { }, viewMode: 'grid', allCollections: null, - activeTeam: null, - activeTeamInfo: null, teams: [], recentSandboxesByTime: derived(({ sandboxes }: State) => { const recentSandboxes = sandboxes.RECENT; diff --git a/packages/app/src/app/overmind/state.ts b/packages/app/src/app/overmind/state.ts index 7950533d4b4..05084cd83f6 100755 --- a/packages/app/src/app/overmind/state.ts +++ b/packages/app/src/app/overmind/state.ts @@ -4,6 +4,7 @@ import { Sandbox, UploadFile, } from '@codesandbox/common/lib/types'; +import { CurrentTeamInfoFragmentFragment as CurrentTeam } from 'app/graphql/types'; import { derived } from 'overmind'; import { hasLogIn } from './utils/user'; @@ -19,6 +20,8 @@ type State = { error: string | null; contributors: string[]; user: CurrentUser | null; + activeTeam: string | null; + activeTeamInfo: CurrentTeam | null; connected: boolean; notifications: Notification[]; isLoadingCLI: boolean; @@ -62,6 +65,8 @@ export const state: State = { authToken: null, error: null, user: null, + activeTeam: null, + activeTeamInfo: null, connected: true, notifications: [], contributors: [], diff --git a/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxCard.tsx b/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxCard.tsx index f5f9dbb9f60..3a722692fa5 100644 --- a/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxCard.tsx +++ b/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxCard.tsx @@ -9,11 +9,143 @@ import { SkeletonText, } from '@codesandbox/components'; import css from '@styled-system/css'; +import { SandboxItemComponentProps } from './types'; + +const useImageLoaded = (url: string) => { + const [loaded, setLoaded] = React.useState(false); + React.useEffect(() => { + const img = new Image(); + img.onload = () => { + setLoaded(true); + }; + + img.src = url; + + if (img.complete) { + setLoaded(true); + } + }, [url]); + + return loaded; +}; + +type SandboxTitleProps = { stoppedScrolling: boolean } & Pick< + SandboxItemComponentProps, + | 'editing' + | 'onContextMenu' + | 'onSubmit' + | 'onChange' + | 'onInputKeyDown' + | 'onInputBlur' + | 'PrivacyIcon' + | 'newTitle' + | 'sandboxTitle' +>; + +const SandboxTitle: React.FC = React.memo( + ({ + editing, + stoppedScrolling, + onContextMenu, + onSubmit, + onChange, + onInputKeyDown, + onInputBlur, + PrivacyIcon, + newTitle, + sandboxTitle, + }) => ( + + {editing ? ( +
+ +
+ ) : ( + + + + {sandboxTitle} + + + )} + + {!stoppedScrolling ? ( + // During scrolling we don't show the button, because it takes 1ms to render a button, + // while this doesn't sound like a lot, we need to render 4 new grid items when you scroll down, + // and this can't take more than 12ms. Showing another thing than the button shaves off a 4ms of + // render time and allows you to scroll with a minimum of 30fps. +
+ +
+ ) : ( + + )} +
+ ) +); + +type SandboxStatsProps = Pick< + SandboxItemComponentProps, + 'isHomeTemplate' | 'sandbox' | 'viewCount' | 'sandboxLocation' | 'lastUpdated' +>; +const SandboxStats: React.FC = React.memo( + ({ isHomeTemplate, sandbox, viewCount, sandboxLocation, lastUpdated }) => ( + + + + + {viewCount} + + + {isHomeTemplate ? null : ( + <> + + • + + + {shortDistance(lastUpdated)} + + + )} + {sandboxLocation ? ( + <> + + • + + + {sandboxLocation} + + + ) : null} + + ) +); export const SandboxCard = ({ sandbox, sandboxTitle, sandboxLocation, + isHomeTemplate, lastUpdated, viewCount, TemplateIcon, @@ -37,16 +169,30 @@ export const SandboxCard = ({ thumbnailRef, opacity, ...props -}) => { +}: SandboxItemComponentProps) => { const [stoppedScrolling, setStoppedScrolling] = React.useState(false); + const [guaranteedScreenshotUrl, setGuaranteedScreenshotUrl] = React.useState< + string + >(screenshotUrl); + + const imageLoaded = useImageLoaded(guaranteedScreenshotUrl); + React.useEffect(() => { // We only want to render the screenshot once the user has stopped scrolling if (!isScrolling && !stoppedScrolling) { setStoppedScrolling(true); } }, [isScrolling, stoppedScrolling]); - const showScreenshot = stoppedScrolling && screenshotUrl; + + React.useEffect(() => { + // We always try to show the cached screenshot first, if someone looks at a sandbox we will try to + // generate a new one based on the latest contents. + const generateScreenshotUrl = `/api/v1/sandboxes/${sandbox.id}/screenshot.png`; + if (stoppedScrolling && !guaranteedScreenshotUrl) { + setGuaranteedScreenshotUrl(generateScreenshotUrl); + } + }, [stoppedScrolling, guaranteedScreenshotUrl, sandbox.id]); return ( theme.speeds[4], opacity, ':hover, :focus, :focus-within': { @@ -90,10 +236,12 @@ export const SandboxCard = ({ }, })} style={{ - [showScreenshot ? 'backgroundImage' : null]: `url(${screenshotUrl})`, + [imageLoaded + ? 'backgroundImage' + : null]: `url(${guaranteedScreenshotUrl})`, }} > - {showScreenshot ? null : } + {imageLoaded ? null : } - - {editing ? ( -
- -
- ) : ( - - - - {sandboxTitle} - - - )} + - -
- - - - - {viewCount} - - - {sandbox.isHomeTemplate ? null : ( - <> - - • - - - {shortDistance(lastUpdated)} - - - )} - {sandboxLocation ? ( - <> - - • - - - {sandboxLocation} - - - ) : null} - + ); }; diff --git a/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxListItem.tsx b/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxListItem.tsx index c9bb9041d4a..5f2325af6ec 100644 --- a/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxListItem.tsx +++ b/packages/app/src/app/pages/NewDashboard/Components/Sandbox/SandboxListItem.tsx @@ -13,6 +13,7 @@ import { Tooltip, } from '@codesandbox/components'; import css from '@styled-system/css'; +import { SandboxItemComponentProps } from './types'; export const SandboxListItem = ({ sandbox, @@ -40,7 +41,7 @@ export const SandboxListItem = ({ thumbnailRef, opacity, ...props -}) => ( +}: SandboxItemComponentProps) => ( null, @@ -105,7 +106,8 @@ const GenericSandbox = ({ isScrolling, item }: GenericSandboxProps) => { if (location.pathname.includes('deleted')) viewMode = 'list'; else viewMode = dashboard.viewMode; - const Component = viewMode === 'list' ? SandboxListItem : SandboxCard; + const Component: React.FC = + viewMode === 'list' ? SandboxListItem : SandboxCard; // interactions const { @@ -130,11 +132,14 @@ const GenericSandbox = ({ isScrolling, item }: GenericSandboxProps) => { onSelectionClick(event, sandbox.id); }; - const onContextMenu = event => { - event.preventDefault(); - if (event.type === 'contextmenu') onRightClick(event, sandbox.id); - else onMenuEvent(event, sandbox.id); - }; + const onContextMenu = React.useCallback( + event => { + event.preventDefault(); + if (event.type === 'contextmenu') onRightClick(event, sandbox.id); + else onMenuEvent(event, sandbox.id); + }, + [onRightClick, onMenuEvent, sandbox.id] + ); const history = useHistory(); const onDoubleClick = event => { @@ -175,32 +180,41 @@ const GenericSandbox = ({ isScrolling, item }: GenericSandboxProps) => { const [newTitle, setNewTitle] = React.useState(sandboxTitle); - const onChange = (event: React.ChangeEvent) => { - setNewTitle(event.target.value); - }; - const onInputKeyDown = (event: React.KeyboardEvent) => { - if (event.keyCode === ESC) { - // Reset value and exit without saving - setNewTitle(sandboxTitle); - setRenaming(false); - } - }; + const onChange = React.useCallback( + (event: React.ChangeEvent) => { + setNewTitle(event.target.value); + }, + [setNewTitle] + ); + const onInputKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.keyCode === ESC) { + // Reset value and exit without saving + setNewTitle(sandboxTitle); + setRenaming(false); + } + }, + [setNewTitle, setRenaming, sandboxTitle] + ); - const onSubmit = async (event?: React.FormEvent) => { - if (event) event.preventDefault(); - await actions.dashboard.renameSandbox({ - id: sandbox.id, - title: newTitle, - oldTitle: sandboxTitle, - }); - setRenaming(false); - track('Dashboard - Rename sandbox', { dashboardVersion: 2 }); - }; + const onSubmit = React.useCallback( + async (event?: React.FormEvent) => { + if (event) event.preventDefault(); + await actions.dashboard.renameSandbox({ + id: sandbox.id, + title: newTitle, + oldTitle: sandboxTitle, + }); + setRenaming(false); + track('Dashboard - Rename sandbox', { dashboardVersion: 2 }); + }, + [actions.dashboard, setRenaming, sandbox.id, newTitle, sandboxTitle] + ); - const onInputBlur = () => { + const onInputBlur = React.useCallback(() => { // save value when you click outside or tab away onSubmit(); - }; + }, [onSubmit]); const interactionProps = { tabIndex: 0, // make div focusable @@ -217,6 +231,7 @@ const GenericSandbox = ({ isScrolling, item }: GenericSandboxProps) => { }; const sandboxProps = { + isHomeTemplate: item.isHomeTemplate, sandboxTitle, sandboxLocation, lastUpdated, diff --git a/packages/app/src/app/pages/NewDashboard/Components/Sandbox/types.ts b/packages/app/src/app/pages/NewDashboard/Components/Sandbox/types.ts new file mode 100644 index 00000000000..fcbceb58b75 --- /dev/null +++ b/packages/app/src/app/pages/NewDashboard/Components/Sandbox/types.ts @@ -0,0 +1,30 @@ +import { DashboardSandbox, DashboardTemplate } from '../../types'; + +export interface SandboxItemComponentProps { + isHomeTemplate: boolean; + sandbox: DashboardSandbox['sandbox'] | DashboardTemplate['sandbox']; + sandboxTitle: string; + sandboxLocation: string; + lastUpdated: string; + viewCount: number | string; + TemplateIcon: React.FC<{ width: string; height: string }>; + PrivacyIcon: React.FC; + screenshotUrl: string | null; + + isScrolling: boolean; + selected: boolean; + onClick: (evt: React.MouseEvent) => void; + onDoubleClick: (evt: React.MouseEvent) => void; + onBlur: (evt: React.FocusEvent) => void; + onContextMenu: (evt: React.MouseEvent) => void; + + editing: boolean; + newTitle: string | null; + onChange: (evt: React.ChangeEvent) => void; + onInputKeyDown: (evt: React.KeyboardEvent) => void; + onSubmit: (evt: React.FormEvent) => void; + onInputBlur: (evt: React.FocusEvent) => void; + + thumbnailRef: React.Ref; + opacity: number; +} diff --git a/packages/app/src/app/pages/NewDashboard/Components/Selection/index.tsx b/packages/app/src/app/pages/NewDashboard/Components/Selection/index.tsx index 1ea6d3f90df..84bdba5a96d 100644 --- a/packages/app/src/app/pages/NewDashboard/Components/Selection/index.tsx +++ b/packages/app/src/app/pages/NewDashboard/Components/Selection/index.tsx @@ -12,6 +12,7 @@ import { ALT, TAB, } from '@codesandbox/common/lib/utils/keycodes'; +import { isEqual } from 'lodash-es'; import { sandboxUrl } from '@codesandbox/common/lib/utils/url-generator'; import { DragPreview } from './DragPreview'; import { ContextMenu } from './ContextMenu'; @@ -164,46 +165,63 @@ export const SelectionProvider: React.FC = ({ const [menuVisible, setMenuVisibility] = React.useState(false); const [menuPosition, setMenuPosition] = React.useState({ x: 0, y: 0 }); - const onRightClick = ( - event: React.MouseEvent & - React.KeyboardEvent, - itemId: string - ) => { - if (!selectedIds.includes(itemId)) setSelectedIds([itemId]); - setMenuVisibility(true); - setMenuPosition({ x: event.clientX, y: event.clientY }); - }; + const onRightClick = React.useCallback( + ( + event: React.MouseEvent & + React.KeyboardEvent, + itemId: string + ) => { + setSelectedIds(s => { + if (!s.includes(itemId)) { + return [itemId]; + } + return s; + }); - const onMenuEvent = ( - event: - | React.MouseEvent - | React.KeyboardEvent, - itemId?: string - ) => { - if (itemId && !selectedIds.includes(itemId)) setSelectedIds([itemId]); + setMenuVisibility(true); + setMenuPosition({ x: event.clientX, y: event.clientY }); + }, + [setMenuVisibility, setMenuPosition] + ); - let menuElement: HTMLElement; - if (event.type === 'click') { - const target = event.target as HTMLButtonElement; - menuElement = target; - } else { - // if the event is fired on the sandbox/folder, we find - // the menu button to correctly position the menu - const selectedItem = selectedIds[selectedIds.length - 1]; - menuElement = document.querySelector( - `[data-selection-id="${selectedItem}"] button` - ); - } + const onMenuEvent = React.useCallback( + ( + event: + | React.MouseEvent + | React.KeyboardEvent, + itemId: string + ) => { + setSelectedIds(s => { + if (itemId && !s.includes(itemId)) { + return [itemId]; + } - const rect = menuElement.getBoundingClientRect(); - const position = { - x: rect.x + rect.width / 2, - y: rect.y + rect.height / 2, - }; + return s; + }); - setMenuVisibility(true); - setMenuPosition(position); - }; + let menuElement: HTMLElement; + if (event.type === 'click') { + const target = event.target as HTMLButtonElement; + menuElement = target; + } else { + // if the event is fired on the sandbox/folder, we find + // the menu button to correctly position the menu + menuElement = document.querySelector( + `[data-selection-id="${itemId}"] button` + ); + } + + const rect = menuElement.getBoundingClientRect(); + const position = { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; + + setMenuVisibility(true); + setMenuPosition(position); + }, + [setSelectedIds, setMenuVisibility, setMenuPosition] + ); const onBlur = (event: React.FocusEvent) => { if (!event.bubbles) { @@ -255,7 +273,8 @@ export const SelectionProvider: React.FC = ({ // disable selection keydowns when renaming if (isRenaming) return; - if (event.keyCode === ALT) onMenuEvent(event); + if (event.keyCode === ALT) + onMenuEvent(event, selectedIds[selectedIds.length - 1]); // if only one thing is selected, open it if (event.keyCode === ENTER && selectedIds.length === 1) { @@ -509,8 +528,10 @@ export const SelectionProvider: React.FC = ({ overlappingIds.push(item.dataset.selectionId); }); - setSelectedIds(overlappingIds); - callbackCalledAt.current = new Date().getTime(); + if (!isEqual(selectedIds, overlappingIds)) { + setSelectedIds(overlappingIds); + callbackCalledAt.current = new Date().getTime(); + } }; // performance hack: don't fire the callback again if it was fired 60ms ago diff --git a/packages/app/src/app/pages/NewDashboard/Content/routes/All/index.tsx b/packages/app/src/app/pages/NewDashboard/Content/routes/All/index.tsx index 7be96e61eb3..579fee24669 100644 --- a/packages/app/src/app/pages/NewDashboard/Content/routes/All/index.tsx +++ b/packages/app/src/app/pages/NewDashboard/Content/routes/All/index.tsx @@ -20,7 +20,8 @@ export const AllPage = () => { const { actions, state: { - dashboard: { allCollections, sandboxes, activeTeam }, + dashboard: { allCollections, sandboxes }, + activeTeam, }, } = useOvermind(); const [localTeam, setLocalTeam] = React.useState(activeTeam); diff --git a/packages/app/src/app/pages/NewDashboard/Content/routes/Deleted/index.tsx b/packages/app/src/app/pages/NewDashboard/Content/routes/Deleted/index.tsx index 1ba55e994be..f725543e140 100644 --- a/packages/app/src/app/pages/NewDashboard/Content/routes/Deleted/index.tsx +++ b/packages/app/src/app/pages/NewDashboard/Content/routes/Deleted/index.tsx @@ -6,6 +6,7 @@ import { Header } from 'app/pages/NewDashboard/Components/Header'; import { VariableGrid } from 'app/pages/NewDashboard/Components/VariableGrid'; import { SelectionProvider } from 'app/pages/NewDashboard/Components/Selection'; import { DashboardGridItem } from 'app/pages/NewDashboard/types'; +import { SandboxFragmentDashboardFragment } from 'app/graphql/types'; import { getPossibleTemplates } from '../../utils'; export const Deleted = () => { @@ -20,14 +21,17 @@ export const Deleted = () => { actions.dashboard.getPage(sandboxesTypes.DELETED); }, [actions.dashboard]); - const getSection = (title, deletedSandboxes) => { + const getSection = ( + title: string, + deletedSandboxes: SandboxFragmentDashboardFragment[] + ): DashboardGridItem[] => { if (!deletedSandboxes.length) return []; return [ { type: 'header', title }, ...deletedSandboxes.map(sandbox => ({ - type: 'sandbox', - ...sandbox, + type: 'sandbox' as 'sandbox', + sandbox, })), ]; }; diff --git a/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/Invite.tsx b/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/Invite.tsx index 8d5f23d6edf..5a6f65397df 100644 --- a/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/Invite.tsx +++ b/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/Invite.tsx @@ -20,9 +20,7 @@ import { Card } from './components'; export const Invite = () => { const { - state: { - dashboard: { activeTeamInfo: team }, - }, + state: { activeTeam, activeTeamInfo: team }, actions, effects, } = useOvermind(); @@ -30,10 +28,13 @@ export const Invite = () => { const inviteLink = team && teamInviteLink(team.inviteToken); React.useEffect(() => { - actions.dashboard.getTeam(); - }, [actions.dashboard]); + actions.getActiveTeam(); + }, [actions, activeTeam]); const [inviteValue, setInviteValue] = React.useState(''); + const [linkCopied, setLinkCopied] = React.useState(false); + const copyLinkTimeoutRef = React.useRef(); + const [loading, setLoading] = React.useState(false); const onSubmit = async event => { @@ -46,6 +47,18 @@ export const Invite = () => { if (!team) return null; + const copyLink = () => { + effects.browser.copyToClipboard(inviteLink); + setLinkCopied(true); + + if (copyLinkTimeoutRef.current) { + window.clearTimeout(copyLinkTimeoutRef.current); + } + copyLinkTimeoutRef.current = window.setTimeout(() => { + setLinkCopied(false); + }, 1500); + }; + return ( <> @@ -95,13 +108,21 @@ export const Invite = () => { })} > - {' '} + ) => { + if (evt.target) { + evt.target.select(); + } + }} + type="text" + value={inviteLink} + /> diff --git a/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/TeamSettings.tsx b/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/TeamSettings.tsx index af5c8d1ed12..727538d2d7e 100644 --- a/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/TeamSettings.tsx +++ b/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/TeamSettings.tsx @@ -23,16 +23,13 @@ import { Card } from './components'; export const TeamSettings = () => { const { - state: { - user: stateUser, - dashboard: { activeTeamInfo: team }, - }, + state: { user: stateUser, activeTeam, activeTeamInfo: team }, actions, } = useOvermind(); useEffect(() => { - actions.dashboard.getTeam(); - }, [actions.dashboard]); + actions.getActiveTeam(); + }, [activeTeam, actions]); const [editing, setEditing] = useState(false); const [loading, setLoading] = useState(false); diff --git a/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/index.tsx b/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/index.tsx index 69da70da403..8f9f9818684 100644 --- a/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/index.tsx +++ b/packages/app/src/app/pages/NewDashboard/Content/routes/Settings/index.tsx @@ -15,7 +15,7 @@ export const Settings = () => { if (location.pathname.includes('settings/new')) Component = NewTeam; else if (location.pathname.includes('invite')) Component = Invite; - else if (state.dashboard.activeTeam) Component = TeamSettings; + else if (state.activeTeam) Component = TeamSettings; else Component = UserSettings; return ( diff --git a/packages/app/src/app/pages/NewDashboard/Content/routes/Templates/index.tsx b/packages/app/src/app/pages/NewDashboard/Content/routes/Templates/index.tsx index 642f544a53d..976d9329e73 100644 --- a/packages/app/src/app/pages/NewDashboard/Content/routes/Templates/index.tsx +++ b/packages/app/src/app/pages/NewDashboard/Content/routes/Templates/index.tsx @@ -13,7 +13,8 @@ export const Templates = () => { const { actions, state: { - dashboard: { sandboxes, getFilteredSandboxes, activeTeam }, + dashboard: { sandboxes, getFilteredSandboxes }, + activeTeam, }, } = useOvermind(); diff --git a/packages/app/src/app/pages/NewDashboard/Sidebar/index.tsx b/packages/app/src/app/pages/NewDashboard/Sidebar/index.tsx index a78386d44b5..9d8fb2a2fae 100644 --- a/packages/app/src/app/pages/NewDashboard/Sidebar/index.tsx +++ b/packages/app/src/app/pages/NewDashboard/Sidebar/index.tsx @@ -24,7 +24,7 @@ import { } from '@codesandbox/components'; import css from '@styled-system/css'; import merge from 'deepmerge'; -import { TeamAvatar } from './TeamAvatar'; +import { TeamAvatar } from 'app/components/TeamAvatar'; import { ContextMenu } from './ContextMenu'; export const SIDEBAR_WIDTH = 240; @@ -32,14 +32,12 @@ export const SIDEBAR_WIDTH = 240; const SidebarContext = React.createContext(null); export const Sidebar = ({ visible, onSidebarToggle, ...props }) => { - const { - state: { dashboard, user }, - actions, - } = useOvermind(); + const { state, actions } = useOvermind(); const [activeAccount, setActiveAccount] = useState({ username: null, avatarUrl: null, }); + const { dashboard, user } = state; React.useEffect(() => { actions.dashboard.getTeams(); @@ -47,13 +45,11 @@ export const Sidebar = ({ visible, onSidebarToggle, ...props }) => { React.useEffect(() => { actions.dashboard.getAllFolders(); - }, [actions.dashboard, dashboard.activeTeam]); + }, [actions.dashboard, state.activeTeam]); React.useEffect(() => { - if (dashboard.activeTeam) { - const team = dashboard.teams.find( - ({ id }) => id === dashboard.activeTeam - ); + if (state.activeTeam) { + const team = dashboard.teams.find(({ id }) => id === state.activeTeam); if (team) setActiveAccount({ username: team.name, avatarUrl: null }); } else if (user) { @@ -62,7 +58,7 @@ export const Sidebar = ({ visible, onSidebarToggle, ...props }) => { avatarUrl: user.avatarUrl, }); } - }, [dashboard.activeTeam, dashboard.activeTeamInfo, dashboard.teams, user]); + }, [state.activeTeam, state.activeTeamInfo, dashboard.teams, user]); const inTeamContext = activeAccount && user && activeAccount.username !== user.username; diff --git a/packages/app/src/app/pages/common/Modals/TeamInviteModal/index.tsx b/packages/app/src/app/pages/common/Modals/TeamInviteModal/index.tsx index c72ce2b21c7..8c62547de35 100644 --- a/packages/app/src/app/pages/common/Modals/TeamInviteModal/index.tsx +++ b/packages/app/src/app/pages/common/Modals/TeamInviteModal/index.tsx @@ -9,7 +9,7 @@ import history from 'app/utils/history'; import { Element, Button, Text } from '@codesandbox/components'; import css from '@styled-system/css'; import { useMutation } from '@apollo/react-hooks'; -import { TeamAvatar } from 'app/pages/NewDashboard/Sidebar/TeamAvatar'; +import { TeamAvatar } from 'app/components/TeamAvatar'; export const TeamInviteModal = () => { const { diff --git a/packages/components/src/components/Button/index.tsx b/packages/components/src/components/Button/index.tsx index b9b37eb53fd..1598f837b13 100644 --- a/packages/components/src/components/Button/index.tsx +++ b/packages/components/src/components/Button/index.tsx @@ -98,11 +98,6 @@ const commonStyles = { }, }; -const merge = (...objs) => - objs.reduce(function mergeAll(merged, currentValue = {}) { - return deepmerge(merged, currentValue); - }, {}); - export interface ButtonProps extends React.ButtonHTMLAttributes, IElementProps { @@ -120,8 +115,7 @@ export const Button = React.forwardRef( { variant = 'primary', loading, css = {}, autoWidth, as: pAs, ...props }, ref ) { - const styles = merge(variantStyles[variant], commonStyles, css); - + const styles = deepmerge.all([variantStyles[variant], commonStyles, css]); const usedAs = pAs || (props.to ? Link : 'button'); // default type is button unless props.as was changed const type = usedAs === 'button' && 'button'; diff --git a/packages/components/src/components/ThemeProvider/index.tsx b/packages/components/src/components/ThemeProvider/index.tsx index 95b167d4eb8..00441a93522 100644 --- a/packages/components/src/components/ThemeProvider/index.tsx +++ b/packages/components/src/components/ThemeProvider/index.tsx @@ -15,6 +15,7 @@ import designLanguage from '../../design-language/theme'; import VSCodeThemes from '../../themes'; import polyfillTheme from '../../utils/polyfill-theme'; import codesandboxBlack from '../../themes/codesandbox-black'; +import { TooltipStyles } from '../Tooltip'; export const getThemes = () => { const results = VSCodeThemes.map(theme => ({ @@ -89,7 +90,10 @@ export const ThemeProvider = ({ return ( <> - {children} + + + {children} + ); }; diff --git a/packages/components/src/components/Tooltip/index.tsx b/packages/components/src/components/Tooltip/index.tsx index d0a8c53ff5b..d1b38302727 100644 --- a/packages/components/src/components/Tooltip/index.tsx +++ b/packages/components/src/components/Tooltip/index.tsx @@ -58,7 +58,7 @@ const animation = styledcss` * so we apply global styles with their [data-reach-name] */ -const TooltipStyles = createGlobalStyle( +export const TooltipStyles = createGlobalStyle( css({ '[data-reach-tooltip][data-component=Tooltip]': { backgroundColor: 'grays.900', @@ -93,7 +93,6 @@ const Tooltip = props => { return ( <> - {React.cloneElement(props.children, trigger)}