Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

workspace dropdown list #9

Merged
merged 15 commits into from
Jun 16, 2023
4 changes: 4 additions & 0 deletions src/core/public/workspace/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
export const WORKSPACES_API_BASE_URL = '/api/workspaces';

export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_';

export enum WORKSPACE_ERROR_REASON_MAP {
WORKSPACE_STALED = 'WORKSPACE_STALED',
}
83 changes: 31 additions & 52 deletions src/core/public/workspace/workspaces_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/
import { resolve as resolveUrl } from 'url';
import type { PublicContract } from '@osd/utility-types';
import { Subject } from 'rxjs';
import { BehaviorSubject } from 'rxjs';
import { HttpFetchError, HttpFetchOptions, HttpSetup } from '../http';
import { WorkspaceAttribute, WorkspaceFindOptions } from '.';
import { WORKSPACES_API_BASE_URL } from './consts';
import { WORKSPACES_API_BASE_URL, WORKSPACE_ERROR_REASON_MAP } from './consts';

/**
* WorkspacesClientContract as implemented by the {@link WorkspacesClient}
Expand Down Expand Up @@ -40,27 +39,25 @@ type IResponse<T> =
*/
export class WorkspacesClient {
private http: HttpSetup;
private currentWorkspaceId = '';
public currentWorkspaceId$ = new Subject<string>();
public workspaceList$ = new Subject<WorkspaceAttribute[]>();
public currentWorkspaceId$ = new BehaviorSubject<string>('');
public workspaceList$ = new BehaviorSubject<WorkspaceAttribute[]>([]);
constructor(http: HttpSetup) {
this.http = http;
this.currentWorkspaceId$.subscribe(
(currentWorkspaceId) => (this.currentWorkspaceId = currentWorkspaceId)
);
/**
* Add logic to check if current workspace id is still valid
* If not, remove the current workspace id and notify other subscribers
*/
this.workspaceList$.subscribe(async (workspaceList) => {
const currentWorkspaceId = this.currentWorkspaceId;
const currentWorkspaceId = this.currentWorkspaceId$.getValue();
if (currentWorkspaceId) {
const findItem = workspaceList.find((item) => item.id === currentWorkspaceId);
if (!findItem) {
/**
* Current workspace is staled
*/
this.currentWorkspaceId$.next('');
this.currentWorkspaceId$.error({
reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED,
});
}
}
});
Expand All @@ -71,29 +68,39 @@ export class WorkspacesClient {
this.updateWorkspaceListAndNotify();
}

private catchedFetch = async <T extends IResponse<any>>(
/**
* Add a non-throw-error fetch method for internal use.
*/
private safeFetch = async <T = any>(
path: string,
options: HttpFetchOptions
) => {
): Promise<IResponse<T>> => {
try {
return await this.http.fetch<T>(path, options);
return await this.http.fetch<IResponse<T>>(path, options);
} catch (error: unknown) {
if (error instanceof HttpFetchError || error instanceof Error) {
if (error instanceof HttpFetchError) {
return {
success: false,
error: error.body?.message || error.body?.error || error.message,
};
}

if (error instanceof Error) {
return {
success: false,
error: error.message,
} as T;
};
}

return {
success: false,
error: 'Unknown error',
} as T;
};
}
};

private getPath(path: Array<string | undefined>): string {
return resolveUrl(`${WORKSPACES_API_BASE_URL}/`, join(...path));
return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/');
}

private async updateWorkspaceListAndNotify(): Promise<void> {
Expand Down Expand Up @@ -128,7 +135,7 @@ export class WorkspacesClient {
}

public async getCurrentWorkspaceId(): Promise<IResponse<WorkspaceAttribute['id']>> {
const currentWorkspaceId = this.currentWorkspaceId;
const currentWorkspaceId = this.currentWorkspaceId$.getValue();
if (!currentWorkspaceId) {
return {
success: false,
Expand Down Expand Up @@ -161,16 +168,9 @@ export class WorkspacesClient {
public async create(
attributes: Omit<WorkspaceAttribute, 'id'>
): Promise<IResponse<WorkspaceAttribute>> {
if (!attributes) {
return {
success: false,
error: 'Workspace attributes is required',
};
}

const path = this.getPath([]);

const result = await this.catchedFetch<IResponse<WorkspaceAttribute>>(path, {
const result = await this.safeFetch<WorkspaceAttribute>(path, {
method: 'POST',
body: JSON.stringify({
attributes,
Expand All @@ -191,14 +191,7 @@ export class WorkspacesClient {
* @returns
*/
public async delete(id: string): Promise<IResponse<null>> {
if (!id) {
return {
success: false,
error: 'Id is required.',
};
}

const result = await this.catchedFetch(this.getPath([id]), { method: 'DELETE' });
const result = await this.safeFetch<null>(this.getPath([id]), { method: 'DELETE' });

if (result.success) {
this.updateWorkspaceListAndNotify();
Expand Down Expand Up @@ -230,7 +223,7 @@ export class WorkspacesClient {
}>
> => {
const path = this.getPath(['_list']);
return this.catchedFetch(path, {
return this.safeFetch(path, {
method: 'POST',
body: JSON.stringify(options || {}),
});
Expand All @@ -243,15 +236,8 @@ export class WorkspacesClient {
* @returns The workspace for the given id.
*/
public async get(id: string): Promise<IResponse<WorkspaceAttribute>> {
if (!id) {
return {
success: false,
error: 'Id is required.',
};
}

const path = this.getPath([id]);
return this.catchedFetch(path, {
return this.safeFetch(path, {
method: 'GET',
});
}
Expand All @@ -267,19 +253,12 @@ export class WorkspacesClient {
id: string,
attributes: Partial<WorkspaceAttribute>
): Promise<IResponse<boolean>> {
if (!id || !attributes) {
return {
success: false,
error: 'Id and attributes are required.',
};
}

const path = this.getPath([id]);
const body = {
attributes,
};

const result = await this.catchedFetch(path, {
const result = await this.safeFetch(path, {
method: 'PUT',
body: JSON.stringify(body),
});
Expand Down
10 changes: 4 additions & 6 deletions src/core/server/workspaces/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import { InternalHttpServiceSetup } from '../../http';
import { Logger } from '../../logging';
import { IWorkspaceDBImpl } from '../types';

export const WORKSPACES_API_BASE_URL = '/api/workspaces';

export const WORKSPACE_ID_QUERYSTRING_NAME = '_workspace_id_';
const WORKSPACES_API_BASE_URL = '/api/workspaces';

export function registerRoutes({
client,
Expand Down Expand Up @@ -71,7 +69,7 @@ export function registerRoutes({
);
router.post(
{
path: '/',
path: '',
validate: {
body: schema.object({
attributes: schema.object({
Expand All @@ -98,7 +96,7 @@ export function registerRoutes({
);
router.put(
{
path: '/{id}',
path: '/{id?}',
validate: {
params: schema.object({
id: schema.string(),
Expand Down Expand Up @@ -130,7 +128,7 @@ export function registerRoutes({
);
router.delete(
{
path: '/{id}',
path: '/{id?}',
validate: {
params: schema.object({
id: schema.string(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { WorkspaceDropdownList } from './workspace_dropdown_list';

export { WorkspaceDropdownList };
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React, { useState, useCallback } from 'react';

import { EuiButton, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
import { useEffect } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { CoreStart, WorkspaceAttribute } from '../../../../../core/public';

type WorkspaceOption = EuiComboBoxOptionOption<WorkspaceAttribute>;

interface WorkspaceDropdownListProps {
coreStart: CoreStart;
onCreateWorkspace: () => void;
onSwitchWorkspace: (workspaceId: string) => void;
}

function workspaceToOption(workspace: WorkspaceAttribute): WorkspaceOption {
return { label: workspace.name, key: workspace.id, value: workspace };
}

export function WorkspaceDropdownList(props: WorkspaceDropdownListProps) {
const { coreStart, onCreateWorkspace, onSwitchWorkspace } = props;
const [loading, setLoading] = useState(true);
const [currentWorkspace, setCurrentWorkspace] = useState([] as WorkspaceOption[]);
zhichao-aws marked this conversation as resolved.
Show resolved Hide resolved
const [workspaceOptions, setWorkspaceOptions] = useState([] as WorkspaceOption[]);
zhichao-aws marked this conversation as resolved.
Show resolved Hide resolved
const workspaceList = useObservable(coreStart.workspaces.client.workspaceList$, []);
const currentWorkspaceId = useObservable(coreStart.workspaces.client.currentWorkspaceId$, '');

useEffect(() => {
(async () => {
if (!currentWorkspaceId) {
setCurrentWorkspace([]);
setLoading(false);
return;
}
setLoading(true);
const response = await coreStart.workspaces.client.get(currentWorkspaceId);
if (response.success) {
const currentWorkspaceOption = workspaceToOption(response.result);
setCurrentWorkspace([currentWorkspaceOption]);
} else {
coreStart.notifications.toasts.addDanger({
title: 'Failed to get current workspaces',
text: response.error,
});
setCurrentWorkspace([]);
}
setLoading(false);
})();
}, [coreStart, currentWorkspaceId]);

const onSearchChange = useCallback(
(searchValue: string) => {
const allWorkspaceOptions = workspaceList.map(workspaceToOption);
setWorkspaceOptions(allWorkspaceOptions.filter((item) => item.label.includes(searchValue)));
},
[workspaceList]
);

const onChange = (workspaceOption: WorkspaceOption[]) => {
/** switch the workspace */
onSwitchWorkspace(workspaceOption[0].key!);
setCurrentWorkspace(workspaceOption);
};

useEffect(() => {
onSearchChange('');
}, [onSearchChange]);

return (
<>
<EuiComboBox
async
options={workspaceOptions}
isLoading={loading}
onChange={onChange}
selectedOptions={currentWorkspace}
singleSelection={{ asPlainText: true }}
onSearchChange={onSearchChange}
append={<EuiButton onClick={onCreateWorkspace}>Create workspace</EuiButton>}
/>
</>
);
}
28 changes: 28 additions & 0 deletions src/plugins/workspace/public/mount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import ReactDOM from 'react-dom';
import { CoreStart } from '../../../core/public';
import { WorkspaceDropdownList } from './containers/workspace_dropdown_list';

export const mountDropdownList = (core: CoreStart) => {
core.chrome.navControls.registerLeft({
order: 0,
mount: (element) => {
ReactDOM.render(
<WorkspaceDropdownList
coreStart={core}
onCreateWorkspace={() => alert('create')}
onSwitchWorkspace={(id: string) => alert(`switch to workspace ${id}`)}
/>,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
},
});
};
Loading