This commit is contained in:
Steve Faulkner 2020-08-12 19:23:12 -05:00
commit a8eeeebb59
69 changed files with 1992 additions and 542 deletions

View File

@ -134,6 +134,11 @@ jobs:
NODE_TLS_REJECT_UNAUTHORIZED: 0 NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_SQL }} CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_SQL }}
- uses: actions/upload-artifact@v2
name: videos
if: ${{ failure() }}
with:
path: "**/*.mp4"
endtoendmongo: endtoendmongo:
name: "End To End Tests | Mongo" name: "End To End Tests | Mongo"
needs: [lint, format, compile, unittest] needs: [lint, format, compile, unittest]
@ -163,6 +168,11 @@ jobs:
NODE_TLS_REJECT_UNAUTHORIZED: 0 NODE_TLS_REJECT_UNAUTHORIZED: 0
CYPRESS_CACHE_FOLDER: ~/.cache/Cypress CYPRESS_CACHE_FOLDER: ~/.cache/Cypress
CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_MONGO }} CYPRESS_CONNECTION_STRING: ${{ secrets.CONNECTION_STRING_MONGO }}
- uses: actions/upload-artifact@v2
if: ${{ failure() }}
name: videos
with:
path: "**/*.mp4"
accessibility: accessibility:
name: "Accessibility | Hosted" name: "Accessibility | Hosted"
needs: [lint, format, compile, unittest] needs: [lint, format, compile, unittest]

View File

@ -3,7 +3,7 @@
"pluginsFile": false, "pluginsFile": false,
"fixturesFolder": false, "fixturesFolder": false,
"supportFile": "./support/index.js", "supportFile": "./support/index.js",
"defaultCommandTimeout": 60000, "defaultCommandTimeout": 90000,
"chromeWebSecurity": false, "chromeWebSecurity": false,
"reporter": "mochawesome", "reporter": "mochawesome",
"reporterOptions": { "reporterOptions": {

View File

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"test": "cypress run", "test": "cypress run",
"wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/", "wait-for-server": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/",
"test:sql": "cypress run --browser chrome --headless --spec \"./integration/dataexplorer/SQL/*\"", "test:sql": "cypress run --browser chrome --spec \"./integration/dataexplorer/SQL/*\"",
"test:ci": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/ https-get://0.0.0.0:8081/_explorer/index.html && cypress run --browser chrome --headless", "test:ci": "wait-on -t 240000 -i 5000 -v https-get://0.0.0.0:1234/ https-get://0.0.0.0:8081/_explorer/index.html && cypress run --browser chrome --headless",
"test:debug": "cypress open" "test:debug": "cypress open"
}, },

View File

@ -7,6 +7,12 @@ export class AuthorizationEndpoints {
public static common: string = "https://login.windows.net/"; public static common: string = "https://login.windows.net/";
} }
export class CodeOfConductEndpoints {
public static privacyStatement: string = "https://aka.ms/ms-privacy-policy";
public static codeOfConduct: string = "https://aka.ms/cosmos-code-of-conduct";
public static termsOfUse: string = "https://aka.ms/ms-terms-of-use";
}
export class BackendEndpoints { export class BackendEndpoints {
public static localhost: string = "https://localhost:12900"; public static localhost: string = "https://localhost:12900";
public static dev: string = "https://ext.documents-dev.windows-int.net"; public static dev: string = "https://ext.documents-dev.windows-int.net";
@ -14,7 +20,10 @@ export class BackendEndpoints {
} }
export class EndpointsRegex { export class EndpointsRegex {
public static readonly cassandra = "AccountEndpoint=(.*).cassandra.cosmosdb.azure.com"; public static readonly cassandra = [
"AccountEndpoint=(.*).cassandra.cosmosdb.azure.com",
"HostName=(.*).cassandra.cosmos.azure.com"
];
public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com"; public static readonly mongo = "mongodb://.*:(.*)@(.*).documents.azure.com";
public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com"; public static readonly mongoCompute = "mongodb://.*:(.*)@(.*).mongo.cosmos.azure.com";
public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com"; public static readonly sql = "AccountEndpoint=https://(.*).documents.azure.com";
@ -113,6 +122,8 @@ export class Features {
public static readonly enableTtl = "enablettl"; public static readonly enableTtl = "enablettl";
public static readonly enableNotebooks = "enablenotebooks"; public static readonly enableNotebooks = "enablenotebooks";
public static readonly enableGalleryPublish = "enablegallerypublish"; public static readonly enableGalleryPublish = "enablegallerypublish";
public static readonly enableCodeOfConduct = "enablecodeofconduct";
public static readonly enableLinkInjection = "enablelinkinjection";
public static readonly enableSpark = "enablespark"; public static readonly enableSpark = "enablespark";
public static readonly livyEndpoint = "livyendpoint"; public static readonly livyEndpoint = "livyendpoint";
public static readonly notebookServerUrl = "notebookserverurl"; public static readonly notebookServerUrl = "notebookserverurl";

View File

@ -26,7 +26,6 @@ import { OfferUtils } from "../Utils/OfferUtils";
import { RequestOptions } from "@azure/cosmos/dist-esm"; import { RequestOptions } from "@azure/cosmos/dist-esm";
import StoredProcedure from "../Explorer/Tree/StoredProcedure"; import StoredProcedure from "../Explorer/Tree/StoredProcedure";
import { Platform, configContext } from "../ConfigContext"; import { Platform, configContext } from "../ConfigContext";
import { getAuthorizationHeader } from "../Utils/AuthorizationUtils";
import DocumentId from "../Explorer/Tree/DocumentId"; import DocumentId from "../Explorer/Tree/DocumentId";
import ConflictId from "../Explorer/Tree/ConflictId"; import ConflictId from "../Explorer/Tree/ConflictId";
@ -419,26 +418,6 @@ export function deleteTrigger(
); );
} }
export function readCollections(database: ViewModels.Database, options: any): Q.Promise<DataModels.Collection[]> {
return Q(
client()
.database(database.id())
.containers.readAll()
.fetchAll()
.then(response => response.resources as DataModels.Collection[])
);
}
export function readCollection(databaseId: string, collectionId: string): Q.Promise<DataModels.Collection> {
return Q(
client()
.database(databaseId)
.container(collectionId)
.read()
.then(response => response.resource as DataModels.Collection)
);
}
export function readCollectionQuotaInfo( export function readCollectionQuotaInfo(
collection: ViewModels.Collection, collection: ViewModels.Collection,
options: any options: any
@ -508,26 +487,6 @@ export function readOffer(requestedResource: DataModels.Offer, options: any): Q.
); );
} }
export function readDatabases(options: any): Q.Promise<DataModels.Database[]> {
try {
if (configContext.platform === Platform.Portal) {
return sendCachedDataMessage<DataModels.Database[]>(MessageTypes.AllDatabases, [
(<any>window).dataExplorer.databaseAccount().id,
Constants.ClientDefaults.portalCacheTimeoutMs
]);
}
} catch (error) {
// If error getting cached DBs, continue on and read via SDK
}
return Q(
client()
.databases.readAll()
.fetchAll()
.then(response => response.resources as DataModels.Database[])
);
}
export function getOrCreateDatabaseAndCollection( export function getOrCreateDatabaseAndCollection(
request: DataModels.CreateDatabaseAndCollectionRequest, request: DataModels.CreateDatabaseAndCollectionRequest,
options: any options: any
@ -640,29 +599,6 @@ export function queryConflicts(
return Q(documentsIterator); return Q(documentsIterator);
} }
export async function updateOfferThroughputBeyondLimit(
request: DataModels.UpdateOfferThroughputRequest
): Promise<void> {
if (configContext.platform !== Platform.Portal) {
throw new Error("Updating throughput beyond specified limit is not supported on this platform");
}
const explorer = window.dataExplorer;
const url = `${explorer.extensionEndpoint()}/api/offerthroughputrequest/updatebeyondspecifiedlimit`;
const authorizationHeader = getAuthorizationHeader();
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(request),
headers: { [authorizationHeader.header]: authorizationHeader.token }
});
if (response.ok) {
return undefined;
}
throw new Error(await response.text());
}
function _createDatabase(request: DataModels.CreateDatabaseRequest, options: any = {}): Q.Promise<DataModels.Database> { function _createDatabase(request: DataModels.CreateDatabaseRequest, options: any = {}): Q.Promise<DataModels.Database> {
const { databaseId, databaseLevelThroughput, offerThroughput, autoPilot, hasAutoPilotV2FeatureFlag } = request; const { databaseId, databaseLevelThroughput, offerThroughput, autoPilot, hasAutoPilotV2FeatureFlag } = request;
const createBody: DatabaseRequest = { id: databaseId }; const createBody: DatabaseRequest = { id: databaseId };

View File

@ -383,40 +383,6 @@ export function updateOffer(
return deferred.promise; return deferred.promise;
} }
export function updateOfferThroughputBeyondLimit(
requestPayload: DataModels.UpdateOfferThroughputRequest
): Q.Promise<void> {
const deferred: Q.Deferred<void> = Q.defer<void>();
const resourceDescriptionInfo: string = requestPayload.collectionName
? `database ${requestPayload.databaseName} and container ${requestPayload.collectionName}`
: `database ${requestPayload.databaseName}`;
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Requesting increase in throughput to ${requestPayload.throughput} for ${resourceDescriptionInfo}`
);
DataAccessUtilityBase.updateOfferThroughputBeyondLimit(requestPayload)
.then(
() => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Info,
`Successfully requested an increase in throughput to ${requestPayload.throughput} for ${resourceDescriptionInfo}`
);
deferred.resolve();
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Failed to request an increase in throughput for ${requestPayload.throughput}: ${JSON.stringify(error)}`
);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => NotificationConsoleUtils.clearInProgressMessageWithId(id));
return deferred.promise.timeout(Constants.ClientDefaults.requestTimeoutMs);
}
export function updateStoredProcedure( export function updateStoredProcedure(
collection: ViewModels.Collection, collection: ViewModels.Collection,
storedProcedure: DataModels.StoredProcedure, storedProcedure: DataModels.StoredProcedure,
@ -840,63 +806,6 @@ export function refreshCachedOffers(): Q.Promise<void> {
return DataAccessUtilityBase.refreshCachedOffers(); return DataAccessUtilityBase.refreshCachedOffers();
} }
export function readCollections(database: ViewModels.Database, options: any = {}): Q.Promise<DataModels.Collection[]> {
var deferred = Q.defer<DataModels.Collection[]>();
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Querying containers for database ${database.id()}`
);
DataAccessUtilityBase.readCollections(database, options)
.then(
(collections: DataModels.Collection[]) => {
deferred.resolve(collections);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while querying containers for database ${database.id()}:\n ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "ReadCollections", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function readCollection(databaseId: string, collectionId: string): Q.Promise<DataModels.Collection> {
const deferred = Q.defer<DataModels.Collection>();
const id = NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.InProgress,
`Querying container ${collectionId}`
);
DataAccessUtilityBase.readCollection(databaseId, collectionId)
.then(
(collection: DataModels.Collection) => {
deferred.resolve(collection);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while querying containers for database ${databaseId}:\n ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "ReadCollections", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function readCollectionQuotaInfo( export function readCollectionQuotaInfo(
collection: ViewModels.Collection, collection: ViewModels.Collection,
options?: any options?: any
@ -984,31 +893,6 @@ export function readOffer(
return deferred.promise; return deferred.promise;
} }
export function readDatabases(options: any): Q.Promise<DataModels.Database[]> {
var deferred = Q.defer<any>();
const id = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, "Querying databases");
DataAccessUtilityBase.readDatabases(options)
.then(
(databases: DataModels.Database[]) => {
deferred.resolve(databases);
},
(error: any) => {
NotificationConsoleUtils.logConsoleMessage(
ConsoleDataType.Error,
`Error while querying databases:\n ${JSON.stringify(error)}`
);
Logger.logError(JSON.stringify(error), "ReadDatabases", error.code);
sendNotificationForError(error);
deferred.reject(error);
}
)
.finally(() => {
NotificationConsoleUtils.clearInProgressMessageWithId(id);
});
return deferred.promise;
}
export function getOrCreateDatabaseAndCollection( export function getOrCreateDatabaseAndCollection(
request: DataModels.CreateDatabaseAndCollectionRequest, request: DataModels.CreateDatabaseAndCollectionRequest,
options: any = {} options: any = {}

View File

@ -1,4 +1,4 @@
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import * as ViewModels from "../Contracts/ViewModels"; import * as ViewModels from "../Contracts/ViewModels";
export function replaceKnownError(err: string): string { export function replaceKnownError(err: string): string {

View File

@ -1,23 +1,46 @@
jest.mock("../../Utils/arm/request"); jest.mock("../../Utils/arm/request");
jest.mock("../MessageHandler"); jest.mock("../MessageHandler");
jest.mock("../CosmosClient");
import { deleteCollection } from "./deleteCollection"; import { deleteCollection } from "./deleteCollection";
import { armRequest } from "../../Utils/arm/request"; import { armRequest } from "../../Utils/arm/request";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
import { client } from "../CosmosClient";
import { updateUserContext } from "../../UserContext"; import { updateUserContext } from "../../UserContext";
import { DatabaseAccount } from "../../Contracts/DataModels"; import { DatabaseAccount } from "../../Contracts/DataModels";
import { sendCachedDataMessage } from "../MessageHandler"; import { sendCachedDataMessage } from "../MessageHandler";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
describe("deleteCollection", () => { describe("deleteCollection", () => {
it("should call ARM if logged in with AAD", async () => { beforeAll(() => {
window.authType = AuthType.AAD;
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {
name: "test" name: "test"
} as DatabaseAccount } as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
}); });
(sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined); (sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined);
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
await deleteCollection("database", "collection"); await deleteCollection("database", "collection");
expect(armRequest).toHaveBeenCalled(); expect(armRequest).toHaveBeenCalled();
}); });
// TODO: Test non-AAD case
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
container: () => {
return {
delete: (): unknown => undefined
};
}
};
}
});
await deleteCollection("database", "collection");
expect(client).toHaveBeenCalled();
});
}); });

View File

@ -1,6 +1,13 @@
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { deleteSqlContainer } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources"; import { deleteSqlContainer } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { deleteCassandraTable } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { deleteMongoDBCollection } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { deleteGremlinGraph } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { deleteTable } from "../../Utils/arm/generatedClients/2020-04-01/tableResources";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { client } from "../CosmosClient"; import { client } from "../CosmosClient";
import { refreshCachedResources } from "../DataAccessUtilityBase"; import { refreshCachedResources } from "../DataAccessUtilityBase";
@ -9,13 +16,7 @@ export async function deleteCollection(databaseId: string, collectionId: string)
const clearMessage = logConsoleProgress(`Deleting container ${collectionId}`); const clearMessage = logConsoleProgress(`Deleting container ${collectionId}`);
try { try {
if (window.authType === AuthType.AAD) { if (window.authType === AuthType.AAD) {
await deleteSqlContainer( await deleteCollectionWithARM(databaseId, collectionId);
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
databaseId,
collectionId
);
} else { } else {
await client() await client()
.database(databaseId) .database(databaseId)
@ -24,9 +25,33 @@ export async function deleteCollection(databaseId: string, collectionId: string)
} }
} catch (error) { } catch (error) {
logConsoleError(`Error while deleting container ${collectionId}:\n ${JSON.stringify(error)}`); logConsoleError(`Error while deleting container ${collectionId}:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "DeleteCollection", error.code);
sendNotificationForError(error);
throw error; throw error;
} }
logConsoleInfo(`Successfully deleted container ${collectionId}`); logConsoleInfo(`Successfully deleted container ${collectionId}`);
clearMessage(); clearMessage();
await refreshCachedResources(); await refreshCachedResources();
} }
function deleteCollectionWithARM(databaseId: string, collectionId: string): Promise<void> {
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return deleteSqlContainer(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.MongoDB:
return deleteMongoDBCollection(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.Cassandra:
return deleteCassandraTable(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.Graph:
return deleteGremlinGraph(subscriptionId, resourceGroup, accountName, databaseId, collectionId);
case DefaultAccountExperienceType.Table:
return deleteTable(subscriptionId, resourceGroup, accountName, collectionId);
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}

View File

@ -1,23 +1,42 @@
jest.mock("../../Utils/arm/request"); jest.mock("../../Utils/arm/request");
jest.mock("../MessageHandler"); jest.mock("../MessageHandler");
jest.mock("../CosmosClient");
import { deleteDatabase } from "./deleteDatabase"; import { deleteDatabase } from "./deleteDatabase";
import { armRequest } from "../../Utils/arm/request"; import { armRequest } from "../../Utils/arm/request";
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
import { client } from "../CosmosClient";
import { updateUserContext } from "../../UserContext"; import { updateUserContext } from "../../UserContext";
import { DatabaseAccount } from "../../Contracts/DataModels"; import { DatabaseAccount } from "../../Contracts/DataModels";
import { sendCachedDataMessage } from "../MessageHandler"; import { sendCachedDataMessage } from "../MessageHandler";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
describe("deleteDatabase", () => { describe("deleteDatabase", () => {
it("should call ARM if logged in with AAD", async () => { beforeAll(() => {
window.authType = AuthType.AAD;
updateUserContext({ updateUserContext({
databaseAccount: { databaseAccount: {
name: "test" name: "test"
} as DatabaseAccount } as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
}); });
(sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined); (sendCachedDataMessage as jest.Mock).mockResolvedValue(undefined);
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
await deleteDatabase("database"); await deleteDatabase("database");
expect(armRequest).toHaveBeenCalled(); expect(armRequest).toHaveBeenCalled();
}); });
// TODO: Test non-AAD case
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
delete: (): unknown => undefined
};
}
});
await deleteDatabase("database");
expect(client).toHaveBeenCalled();
});
}); });

View File

@ -1,5 +1,9 @@
import { AuthType } from "../../AuthType"; import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { deleteSqlDatabase } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources"; import { deleteSqlDatabase } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { deleteCassandraKeyspace } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { deleteMongoDBDatabase } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { deleteGremlinDatabase } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { logConsoleError, logConsoleInfo, logConsoleProgress } from "../../Utils/NotificationConsoleUtils";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { client } from "../CosmosClient"; import { client } from "../CosmosClient";
@ -12,12 +16,7 @@ export async function deleteDatabase(databaseId: string): Promise<void> {
try { try {
if (window.authType === AuthType.AAD) { if (window.authType === AuthType.AAD) {
await deleteSqlDatabase( await deleteDatabaseWithARM(databaseId);
userContext.subscriptionId,
userContext.resourceGroup,
userContext.databaseAccount.name,
databaseId
);
} else { } else {
await client() await client()
.database(databaseId) .database(databaseId)
@ -33,3 +32,23 @@ export async function deleteDatabase(databaseId: string): Promise<void> {
clearMessage(); clearMessage();
await refreshCachedResources(); await refreshCachedResources();
} }
function deleteDatabaseWithARM(databaseId: string): Promise<void> {
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
return deleteSqlDatabase(subscriptionId, resourceGroup, accountName, databaseId);
case DefaultAccountExperienceType.MongoDB:
return deleteMongoDBDatabase(subscriptionId, resourceGroup, accountName, databaseId);
case DefaultAccountExperienceType.Cassandra:
return deleteCassandraKeyspace(subscriptionId, resourceGroup, accountName, databaseId);
case DefaultAccountExperienceType.Graph:
return deleteGremlinDatabase(subscriptionId, resourceGroup, accountName, databaseId);
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}

View File

@ -0,0 +1,35 @@
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { client } from "../CosmosClient";
import { readCollection } from "./readCollection";
import { updateUserContext } from "../../UserContext";
describe("readCollection", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
});
it("should call SDK if logged in with resource token", async () => {
window.authType = AuthType.ResourceToken;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
container: () => {
return {
read: (): unknown => ({})
};
}
};
}
});
await readCollection("database", "collection");
expect(client).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,24 @@
import * as DataModels from "../../Contracts/DataModels";
import { client } from "../CosmosClient";
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
export async function readCollection(databaseId: string, collectionId: string): Promise<DataModels.Collection> {
let collection: DataModels.Collection;
const clearMessage = logConsoleProgress(`Querying container ${collectionId}`);
try {
const response = await client()
.database(databaseId)
.container(collectionId)
.read();
collection = response.resource as DataModels.Collection;
} catch (error) {
logConsoleError(`Error while querying container ${collectionId}:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "ReadCollection", error.code);
sendNotificationForError(error);
throw error;
}
clearMessage();
return collection;
}

View File

@ -0,0 +1,45 @@
jest.mock("../../Utils/arm/request");
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { armRequest } from "../../Utils/arm/request";
import { client } from "../CosmosClient";
import { readCollections } from "./readCollections";
import { updateUserContext } from "../../UserContext";
describe("readCollections", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
await readCollections("database");
expect(armRequest).toHaveBeenCalled();
});
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
database: () => {
return {
containers: {
readAll: () => {
return {
fetchAll: (): unknown => []
};
}
}
};
}
});
await readCollections("database");
expect(client).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,66 @@
import * as DataModels from "../../Contracts/DataModels";
import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { client } from "../CosmosClient";
import { listSqlContainers } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { listCassandraTables } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { listMongoDBCollections } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { listGremlinGraphs } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { listTables } from "../../Utils/arm/generatedClients/2020-04-01/tableResources";
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
export async function readCollections(databaseId: string): Promise<DataModels.Collection[]> {
let collections: DataModels.Collection[];
const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`);
try {
if (window.authType === AuthType.AAD) {
collections = await readCollectionsWithARM(databaseId);
} else {
const sdkResponse = await client()
.database(databaseId)
.containers.readAll()
.fetchAll();
collections = sdkResponse.resources as DataModels.Collection[];
}
} catch (error) {
logConsoleError(`Error while querying containers for database ${databaseId}:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "ReadCollections", error.code);
sendNotificationForError(error);
throw error;
}
clearMessage();
return collections;
}
async function readCollectionsWithARM(databaseId: string): Promise<DataModels.Collection[]> {
let rpResponse;
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
rpResponse = await listSqlContainers(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.MongoDB:
rpResponse = await listMongoDBCollections(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.Cassandra:
rpResponse = await listCassandraTables(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.Graph:
rpResponse = await listGremlinGraphs(subscriptionId, resourceGroup, accountName, databaseId);
break;
case DefaultAccountExperienceType.Table:
rpResponse = await listTables(subscriptionId, resourceGroup, accountName);
break;
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
return rpResponse?.value?.map(collection => collection.properties?.resource as DataModels.Collection);
}

View File

@ -0,0 +1,41 @@
jest.mock("../../Utils/arm/request");
jest.mock("../CosmosClient");
import { AuthType } from "../../AuthType";
import { DatabaseAccount } from "../../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { armRequest } from "../../Utils/arm/request";
import { client } from "../CosmosClient";
import { readDatabases } from "./readDatabases";
import { updateUserContext } from "../../UserContext";
describe("readDatabases", () => {
beforeAll(() => {
updateUserContext({
databaseAccount: {
name: "test"
} as DatabaseAccount,
defaultExperience: DefaultAccountExperienceType.DocumentDB
});
});
it("should call ARM if logged in with AAD", async () => {
window.authType = AuthType.AAD;
await readDatabases();
expect(armRequest).toHaveBeenCalled();
});
it("should call SDK if not logged in with non-AAD method", async () => {
window.authType = AuthType.MasterKey;
(client as jest.Mock).mockReturnValue({
databases: {
readAll: () => {
return {
fetchAll: (): unknown => []
};
}
}
});
await readDatabases();
expect(client).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,61 @@
import * as DataModels from "../../Contracts/DataModels";
import { AuthType } from "../../AuthType";
import { DefaultAccountExperienceType } from "../../DefaultAccountExperienceType";
import { client } from "../CosmosClient";
import { listSqlDatabases } from "../../Utils/arm/generatedClients/2020-04-01/sqlResources";
import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/2020-04-01/cassandraResources";
import { listMongoDBDatabases } from "../../Utils/arm/generatedClients/2020-04-01/mongoDBResources";
import { listGremlinDatabases } from "../../Utils/arm/generatedClients/2020-04-01/gremlinResources";
import { logConsoleProgress, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { logError } from "../Logger";
import { sendNotificationForError } from "./sendNotificationForError";
import { userContext } from "../../UserContext";
export async function readDatabases(): Promise<DataModels.Database[]> {
let databases: DataModels.Database[];
const clearMessage = logConsoleProgress(`Querying databases`);
try {
if (window.authType === AuthType.AAD) {
databases = await readDatabasesWithARM();
} else {
const sdkResponse = await client()
.databases.readAll()
.fetchAll();
databases = sdkResponse.resources as DataModels.Database[];
}
} catch (error) {
logConsoleError(`Error while querying databases:\n ${JSON.stringify(error)}`);
logError(JSON.stringify(error), "ReadDatabases", error.code);
sendNotificationForError(error);
throw error;
}
clearMessage();
return databases;
}
async function readDatabasesWithARM(): Promise<DataModels.Database[]> {
let rpResponse;
const subscriptionId = userContext.subscriptionId;
const resourceGroup = userContext.resourceGroup;
const accountName = userContext.databaseAccount.name;
const defaultExperience = userContext.defaultExperience;
switch (defaultExperience) {
case DefaultAccountExperienceType.DocumentDB:
rpResponse = await listSqlDatabases(subscriptionId, resourceGroup, accountName);
break;
case DefaultAccountExperienceType.MongoDB:
rpResponse = await listMongoDBDatabases(subscriptionId, resourceGroup, accountName);
break;
case DefaultAccountExperienceType.Cassandra:
rpResponse = await listCassandraKeyspaces(subscriptionId, resourceGroup, accountName);
break;
case DefaultAccountExperienceType.Graph:
rpResponse = await listGremlinDatabases(subscriptionId, resourceGroup, accountName);
break;
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
return rpResponse?.value?.map(database => database.properties?.resource as DataModels.Database);
}

View File

@ -0,0 +1,26 @@
import { updateOfferThroughputBeyondLimit } from "./updateOfferThroughputBeyondLimit";
describe("updateOfferThroughputBeyondLimit", () => {
it("should call fetch", async () => {
window.fetch = jest.fn(() => {
return {
ok: true
};
});
window.dataExplorer = {
logConsoleData: jest.fn(),
deleteInProgressConsoleDataWithId: jest.fn(),
extensionEndpoint: jest.fn()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
await updateOfferThroughputBeyondLimit({
subscriptionId: "foo",
resourceGroup: "foo",
databaseAccountName: "foo",
databaseName: "foo",
throughput: 1000000000,
offerIsRUPerMinuteThroughputEnabled: false
});
expect(window.fetch).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,52 @@
import { Platform, configContext } from "../../ConfigContext";
import { getAuthorizationHeader } from "../../Utils/AuthorizationUtils";
import { AutoPilotOfferSettings } from "../../Contracts/DataModels";
import { logConsoleProgress, logConsoleInfo, logConsoleError } from "../../Utils/NotificationConsoleUtils";
import { HttpHeaders } from "../Constants";
interface UpdateOfferThroughputRequest {
subscriptionId: string;
resourceGroup: string;
databaseAccountName: string;
databaseName: string;
collectionName?: string;
throughput: number;
offerIsRUPerMinuteThroughputEnabled: boolean;
offerAutopilotSettings?: AutoPilotOfferSettings;
}
export async function updateOfferThroughputBeyondLimit(request: UpdateOfferThroughputRequest): Promise<void> {
if (configContext.platform !== Platform.Portal) {
throw new Error("Updating throughput beyond specified limit is not supported on this platform");
}
const resourceDescriptionInfo = request.collectionName
? `database ${request.databaseName} and container ${request.collectionName}`
: `database ${request.databaseName}`;
const clearMessage = logConsoleProgress(
`Requesting increase in throughput to ${request.throughput} for ${resourceDescriptionInfo}`
);
const explorer = window.dataExplorer;
const url = `${explorer.extensionEndpoint()}/api/offerthroughputrequest/updatebeyondspecifiedlimit`;
const authorizationHeader = getAuthorizationHeader();
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(request),
headers: { [authorizationHeader.header]: authorizationHeader.token, [HttpHeaders.contentType]: "application/json" }
});
if (response.ok) {
logConsoleInfo(
`Successfully requested an increase in throughput to ${request.throughput} for ${resourceDescriptionInfo}`
);
clearMessage();
return undefined;
}
const error = await response.json();
logConsoleError(`Failed to request an increase in throughput for ${request.throughput}: ${error.message}`);
clearMessage();
throw new Error(error.message);
}

View File

@ -312,17 +312,6 @@ export interface Query {
query: string; query: string;
} }
export interface UpdateOfferThroughputRequest {
subscriptionId: string;
resourceGroup: string;
databaseAccountName: string;
databaseName: string;
collectionName: string;
throughput: number;
offerIsRUPerMinuteThroughputEnabled: boolean;
offerAutopilotSettings?: AutoPilotOfferSettings;
}
export interface AutoPilotOfferSettings { export interface AutoPilotOfferSettings {
tier?: AutopilotTier; tier?: AutopilotTier;
maximumTierThroughput?: number; maximumTierThroughput?: number;

View File

@ -0,0 +1,8 @@
export enum DefaultAccountExperienceType {
DocumentDB = "DocumentDB",
Graph = "Graph",
MongoDB = "MongoDB",
Table = "Table",
Cassandra = "Cassandra",
ApiForMongoDB = "Azure Cosmos DB for MongoDB API"
}

View File

@ -49,6 +49,12 @@ export const FeaturePanelComponent: React.FunctionComponent = () => {
{ key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" }, { key: "feature.hosteddataexplorerenabled", label: "Hosted Data Explorer (deprecated?)", value: "true" },
{ key: "feature.enablettl", label: "Enable TTL", value: "true" }, { key: "feature.enablettl", label: "Enable TTL", value: "true" },
{ key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" }, { key: "feature.enablegallerypublish", label: "Enable Notebook Gallery Publishing", value: "true" },
{ key: "feature.enablecodeofconduct", label: "Enable Code Of Conduct Acknowledgement", value: "true" },
{
key: "feature.enableLinkInjection",
label: "Enable Injecting Notebook Viewer Link into the first cell",
value: "true"
},
{ key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" }, { key: "feature.canexceedmaximumvalue", label: "Can exceed max value", value: "true" },
{ {
key: "feature.enablefixedcollectionwithsharedthroughput", key: "feature.enablefixedcollectionwithsharedthroughput",

View File

@ -163,8 +163,14 @@ exports[`Feature panel renders all flags 1`] = `
/> />
<StyledCheckboxBase <StyledCheckboxBase
checked={false} checked={false}
key="feature.canexceedmaximumvalue" key="feature.enablecodeofconduct"
label="Can exceed max value" label="Enable Code Of Conduct Acknowledgement"
onChange={[Function]}
/>
<StyledCheckboxBase
checked={false}
key="feature.enableLinkInjection"
label="Enable Injecting Notebook Viewer Link into the first cell"
onChange={[Function]} onChange={[Function]}
/> />
</Stack> </Stack>
@ -172,6 +178,12 @@ exports[`Feature panel renders all flags 1`] = `
className="checkboxRow" className="checkboxRow"
horizontalAlign="space-between" horizontalAlign="space-between"
> >
<StyledCheckboxBase
checked={false}
key="feature.canexceedmaximumvalue"
label="Can exceed max value"
onChange={[Function]}
/>
<StyledCheckboxBase <StyledCheckboxBase
checked={false} checked={false}
key="feature.enablefixedcollectionwithsharedthroughput" key="feature.enablefixedcollectionwithsharedthroughput"

View File

@ -17,7 +17,8 @@ describe("GalleryCardComponent", () => {
isSample: false, isSample: false,
downloads: 0, downloads: 0,
favorites: 0, favorites: 0,
views: 0 views: 0,
newCellId: undefined
}, },
isFavorite: false, isFavorite: false,
showDownload: true, showDownload: true,

View File

@ -0,0 +1,43 @@
import { shallow } from "enzyme";
import * as sinon from "sinon";
import React from "react";
import { CodeOfConductComponent, CodeOfConductComponentProps } from "./CodeOfConductComponent";
import { IJunoResponse, JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes } from "../../../Common/Constants";
describe("CodeOfConductComponent", () => {
let sandbox: sinon.SinonSandbox;
let codeOfConductProps: CodeOfConductComponentProps;
beforeEach(() => {
sandbox = sinon.sandbox.create();
sandbox.stub(JunoClient.prototype, "acceptCodeOfConduct").returns({
status: HttpStatusCodes.OK,
data: true
} as IJunoResponse<boolean>);
const junoClient = new JunoClient(undefined);
codeOfConductProps = {
junoClient: junoClient,
onAcceptCodeOfConduct: jest.fn()
};
});
afterEach(() => {
sandbox.restore();
});
it("renders", () => {
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
expect(wrapper).toMatchSnapshot();
});
it("onAcceptedCodeOfConductCalled", async () => {
const wrapper = shallow(<CodeOfConductComponent {...codeOfConductProps} />);
wrapper
.find(".genericPaneSubmitBtn")
.first()
.simulate("click");
await Promise.resolve();
expect(codeOfConductProps.onAcceptCodeOfConduct).toBeCalled();
});
});

View File

@ -0,0 +1,112 @@
import * as React from "react";
import { JunoClient } from "../../../Juno/JunoClient";
import { HttpStatusCodes, CodeOfConductEndpoints } from "../../../Common/Constants";
import * as Logger from "../../../Common/Logger";
import { logConsoleError } from "../../../Utils/NotificationConsoleUtils";
import { Stack, Text, Checkbox, PrimaryButton, Link } from "office-ui-fabric-react";
export interface CodeOfConductComponentProps {
junoClient: JunoClient;
onAcceptCodeOfConduct: (result: boolean) => void;
}
interface CodeOfConductComponentState {
readCodeOfConduct: boolean;
}
export class CodeOfConductComponent extends React.Component<CodeOfConductComponentProps, CodeOfConductComponentState> {
private descriptionPara1: string;
private descriptionPara2: string;
private descriptionPara3: string;
private link1: { label: string; url: string };
private link2: { label: string; url: string };
constructor(props: CodeOfConductComponentProps) {
super(props);
this.state = {
readCodeOfConduct: false
};
this.descriptionPara1 = "Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement";
this.descriptionPara2 =
"Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.";
this.descriptionPara3 = "In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the ";
this.link1 = { label: "code of conduct", url: CodeOfConductEndpoints.codeOfConduct };
this.link2 = { label: "privacy statement", url: CodeOfConductEndpoints.privacyStatement };
}
private async acceptCodeOfConduct(): Promise<void> {
try {
const response = await this.props.junoClient.acceptCodeOfConduct();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
this.props.onAcceptCodeOfConduct(response.data);
} catch (error) {
const message = `Failed to accept code of conduct: ${error}`;
Logger.logError(message, "CodeOfConductComponent/acceptCodeOfConduct");
logConsoleError(message);
}
}
private onChangeCheckbox = (): void => {
this.setState({ readCodeOfConduct: !this.state.readCodeOfConduct });
};
public render(): JSX.Element {
return (
<Stack tokens={{ childrenGap: 20 }}>
<Stack.Item>
<Text style={{ fontWeight: 500, fontSize: "20px" }}>{this.descriptionPara1}</Text>
</Stack.Item>
<Stack.Item>
<Text>{this.descriptionPara2}</Text>
</Stack.Item>
<Stack.Item>
<Text>
{this.descriptionPara3}
<Link href={this.link1.url} target="_blank">
{this.link1.label}
</Link>
{" and "}
<Link href={this.link2.url} target="_blank">
{this.link2.label}
</Link>
</Text>
</Stack.Item>
<Stack.Item>
<Checkbox
styles={{
label: {
margin: 0,
padding: "2 0 2 0"
},
text: {
fontSize: 12
}
}}
label="I have read and accepted the code of conduct and privacy statement"
onChange={this.onChangeCheckbox}
/>
</Stack.Item>
<Stack.Item>
<PrimaryButton
ariaLabel="Continue"
title="Continue"
onClick={async () => await this.acceptCodeOfConduct()}
tabIndex={0}
className="genericPaneSubmitBtn"
text="Continue"
disabled={!this.state.readCodeOfConduct}
/>
</Stack.Item>
</Stack>
);
}
}

View File

@ -15,7 +15,7 @@ import {
} from "office-ui-fabric-react"; } from "office-ui-fabric-react";
import * as React from "react"; import * as React from "react";
import * as Logger from "../../../Common/Logger"; import * as Logger from "../../../Common/Logger";
import { IGalleryItem, JunoClient } from "../../../Juno/JunoClient"; import { IGalleryItem, JunoClient, IJunoResponse, IPublicGalleryData } from "../../../Juno/JunoClient";
import * as GalleryUtils from "../../../Utils/GalleryUtils"; import * as GalleryUtils from "../../../Utils/GalleryUtils";
import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils"; import * as NotificationConsoleUtils from "../../../Utils/NotificationConsoleUtils";
import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../../Menus/NotificationConsole/NotificationConsoleComponent";
@ -24,6 +24,8 @@ import { GalleryCardComponent, GalleryCardComponentProps } from "./Cards/Gallery
import "./GalleryViewerComponent.less"; import "./GalleryViewerComponent.less";
import { HttpStatusCodes } from "../../../Common/Constants"; import { HttpStatusCodes } from "../../../Common/Constants";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { CodeOfConductComponent } from "./CodeOfConductComponent";
import { InfoComponent } from "./InfoComponent/InfoComponent";
export interface GalleryViewerComponentProps { export interface GalleryViewerComponentProps {
container?: Explorer; container?: Explorer;
@ -60,6 +62,7 @@ interface GalleryViewerComponentState {
sortBy: SortBy; sortBy: SortBy;
searchText: string; searchText: string;
dialogProps: DialogProps; dialogProps: DialogProps;
isCodeOfConductAccepted: boolean;
} }
interface GalleryTabInfo { interface GalleryTabInfo {
@ -86,6 +89,7 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private publicNotebooks: IGalleryItem[]; private publicNotebooks: IGalleryItem[];
private favoriteNotebooks: IGalleryItem[]; private favoriteNotebooks: IGalleryItem[];
private publishedNotebooks: IGalleryItem[]; private publishedNotebooks: IGalleryItem[];
private isCodeOfConductAccepted: boolean;
private columnCount: number; private columnCount: number;
private rowCount: number; private rowCount: number;
@ -100,7 +104,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
selectedTab: props.selectedTab, selectedTab: props.selectedTab,
sortBy: props.sortBy, sortBy: props.sortBy,
searchText: props.searchText, searchText: props.searchText,
dialogProps: undefined dialogProps: undefined,
isCodeOfConductAccepted: undefined
}; };
this.sortingOptions = [ this.sortingOptions = [
@ -134,10 +139,21 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)]; const tabs: GalleryTabInfo[] = [this.createTab(GalleryTab.OfficialSamples, this.state.sampleNotebooks)];
if (this.props.container?.isGalleryPublishEnabled()) { if (this.props.container?.isGalleryPublishEnabled()) {
tabs.push(this.createTab(GalleryTab.PublicGallery, this.state.publicNotebooks)); tabs.push(
this.createPublicGalleryTab(
GalleryTab.PublicGallery,
this.state.publicNotebooks,
this.state.isCodeOfConductAccepted
)
);
tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks)); tabs.push(this.createTab(GalleryTab.Favorites, this.state.favoriteNotebooks));
// explicitly checking if isCodeOfConductAccepted is not false, as it is initially undefined.
// Displaying code of conduct component on gallery load should not be the default behavior.
if (this.state.isCodeOfConductAccepted !== false) {
tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks)); tabs.push(this.createTab(GalleryTab.Published, this.state.publishedNotebooks));
} }
}
const pivotProps: IPivotProps = { const pivotProps: IPivotProps = {
onLinkClick: this.onPivotChange, onLinkClick: this.onPivotChange,
@ -167,6 +183,17 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
); );
} }
private createPublicGalleryTab(
tab: GalleryTab,
data: IGalleryItem[],
acceptedCodeOfConduct: boolean
): GalleryTabInfo {
return {
tab,
content: this.createPublicGalleryTabContent(data, acceptedCodeOfConduct)
};
}
private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo { private createTab(tab: GalleryTab, data: IGalleryItem[]): GalleryTabInfo {
return { return {
tab, tab,
@ -174,6 +201,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
}; };
} }
private createPublicGalleryTabContent(data: IGalleryItem[], acceptedCodeOfConduct: boolean): JSX.Element {
return acceptedCodeOfConduct === false ? (
<CodeOfConductComponent
junoClient={this.props.junoClient}
onAcceptCodeOfConduct={(result: boolean) => {
this.setState({ isCodeOfConductAccepted: result });
}}
/>
) : (
this.createTabContent(data)
);
}
private createTabContent(data: IGalleryItem[]): JSX.Element { private createTabContent(data: IGalleryItem[]): JSX.Element {
return ( return (
<Stack tokens={{ childrenGap: 10 }}> <Stack tokens={{ childrenGap: 10 }}>
@ -187,8 +227,12 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
<Stack.Item styles={{ root: { minWidth: 200 } }}> <Stack.Item styles={{ root: { minWidth: 200 } }}>
<Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} /> <Dropdown options={this.sortingOptions} selectedKey={this.state.sortBy} onChange={this.onDropdownChange} />
</Stack.Item> </Stack.Item>
{this.props.container?.isGalleryPublishEnabled() && (
<Stack.Item>
<InfoComponent />
</Stack.Item>
)}
</Stack> </Stack>
{data && this.createCardsTabContent(data)} {data && this.createCardsTabContent(data)}
</Stack> </Stack>
); );
@ -254,12 +298,19 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> { private async loadPublicNotebooks(searchText: string, sortBy: SortBy, offline: boolean): Promise<void> {
if (!offline) { if (!offline) {
try { try {
const response = await this.props.junoClient.getPublicNotebooks(); let response: IJunoResponse<IPublicGalleryData> | IJunoResponse<IGalleryItem[]>;
if (this.props.container.isCodeOfConductEnabled()) {
response = await this.props.junoClient.fetchPublicNotebooks();
this.isCodeOfConductAccepted = response.data?.metadata.acceptedCodeOfConduct;
this.publicNotebooks = response.data?.notebooksData;
} else {
response = await this.props.junoClient.getPublicNotebooks();
this.publicNotebooks = response.data;
}
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) { if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when loading public notebooks`); throw new Error(`Received HTTP ${response.status} when loading public notebooks`);
} }
this.publicNotebooks = response.data;
} catch (error) { } catch (error) {
const message = `Failed to load public notebooks: ${error}`; const message = `Failed to load public notebooks: ${error}`;
Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks"); Logger.logError(message, "GalleryViewerComponent/loadPublicNotebooks");
@ -268,7 +319,8 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
} }
this.setState({ this.setState({
publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))] publicNotebooks: this.publicNotebooks && [...this.sort(sortBy, this.search(searchText, this.publicNotebooks))],
isCodeOfConductAccepted: this.isCodeOfConductAccepted
}); });
} }
@ -333,12 +385,11 @@ export class GalleryViewerComponent extends React.Component<GalleryViewerCompone
private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean { private isGalleryItemPresent(searchText: string, item: IGalleryItem): boolean {
const toSearch = searchText.trim().toUpperCase(); const toSearch = searchText.trim().toUpperCase();
const searchData: string[] = [ const searchData: string[] = [item.author.toUpperCase(), item.description.toUpperCase(), item.name.toUpperCase()];
item.author.toUpperCase(),
item.description.toUpperCase(), if (item.tags) {
item.name.toUpperCase(), searchData.push(...item.tags.map(tag => tag.toUpperCase()));
...item.tags?.map(tag => tag.toUpperCase()) }
];
for (const data of searchData) { for (const data of searchData) {
if (data?.indexOf(toSearch) !== -1) { if (data?.indexOf(toSearch) !== -1) {

View File

@ -0,0 +1,26 @@
@import "../../../../../less/Common/Constants.less";
.infoPanel, .infoPanelMain {
display: flex;
align-items: center;
}
.infoPanel {
padding-left: 5px;
padding-right: 5px;
}
.infoLabel, .infoLabelMain {
padding-left: 5px
}
.infoLabel {
font-weight: 400
}
.infoIconMain {
color: @AccentMedium
}
.infoIconMain:hover {
color: @BaseMedium
}

View File

@ -0,0 +1,10 @@
import { shallow } from "enzyme";
import React from "react";
import { InfoComponent } from "./InfoComponent";
describe("InfoComponent", () => {
it("renders", () => {
const wrapper = shallow(<InfoComponent />);
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,42 @@
import * as React from "react";
import { Icon, Label, Stack, HoverCard, HoverCardType, Link } from "office-ui-fabric-react";
import { CodeOfConductEndpoints } from "../../../../Common/Constants";
import "./InfoComponent.less";
export class InfoComponent extends React.Component {
private getInfoPanel = (iconName: string, labelText: string, url: string): JSX.Element => {
return (
<Link href={url} target="_blank">
<div className="infoPanel">
<Icon iconName={iconName} styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabel">{labelText}</Label>
</div>
</Link>
);
};
private onHover = (): JSX.Element => {
return (
<Stack tokens={{ childrenGap: 5, padding: 5 }}>
<Stack.Item>{this.getInfoPanel("Script", "Code of Conduct", CodeOfConductEndpoints.codeOfConduct)}</Stack.Item>
<Stack.Item>
{this.getInfoPanel("RedEye", "Privacy Statement", CodeOfConductEndpoints.privacyStatement)}
</Stack.Item>
<Stack.Item>
{this.getInfoPanel("KnowledgeArticle", "Microsoft Terms of Use", CodeOfConductEndpoints.termsOfUse)}
</Stack.Item>
</Stack>
);
};
public render(): JSX.Element {
return (
<HoverCard plainCardProps={{ onRenderPlainCard: this.onHover }} instantOpenOnClick type={HoverCardType.plain}>
<div className="infoPanelMain">
<Icon className="infoIconMain" iconName="Help" styles={{ root: { verticalAlign: "middle" } }} />
<Label className="infoLabelMain">Help</Label>
</div>
</HoverCard>
);
}
}

View File

@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`InfoComponent renders 1`] = `
<StyledHoverCardBase
instantOpenOnClick={true}
plainCardProps={
Object {
"onRenderPlainCard": [Function],
}
}
type="PlainCard"
>
<div
className="infoPanelMain"
>
<Memo(StyledIconBase)
className="infoIconMain"
iconName="Help"
styles={
Object {
"root": Object {
"verticalAlign": "middle",
},
}
}
/>
<StyledLabelBase
className="infoLabelMain"
>
Help
</StyledLabelBase>
</div>
</StyledHoverCardBase>
`;

View File

@ -0,0 +1,75 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CodeOfConductComponent renders 1`] = `
<Stack
tokens={
Object {
"childrenGap": 20,
}
}
>
<StackItem>
<Text
style={
Object {
"fontSize": "20px",
"fontWeight": 500,
}
}
>
Azure CosmosDB Notebook Gallery - Code of Conduct and Privacy Statement
</Text>
</StackItem>
<StackItem>
<Text>
Azure Cosmos DB Notebook Public Gallery contains notebook samples shared by users of Cosmos DB.
</Text>
</StackItem>
<StackItem>
<Text>
In order to access Azure Cosmos DB Notebook Gallery resources, you must accept the
<StyledLinkBase
href="https://aka.ms/cosmos-code-of-conduct"
target="_blank"
>
code of conduct
</StyledLinkBase>
and
<StyledLinkBase
href="https://aka.ms/ms-privacy-policy"
target="_blank"
>
privacy statement
</StyledLinkBase>
</Text>
</StackItem>
<StackItem>
<StyledCheckboxBase
label="I have read and accepted the code of conduct and privacy statement"
onChange={[Function]}
styles={
Object {
"label": Object {
"margin": 0,
"padding": "2 0 2 0",
},
"text": Object {
"fontSize": 12,
},
}
}
/>
</StackItem>
<StackItem>
<CustomizedPrimaryButton
ariaLabel="Continue"
className="genericPaneSubmitBtn"
disabled={true}
onClick={[Function]}
tabIndex={0}
text="Continue"
title="Continue"
/>
</StackItem>
</Stack>
`;

View File

@ -17,7 +17,8 @@ describe("NotebookMetadataComponent", () => {
isSample: false, isSample: false,
downloads: 0, downloads: 0,
favorites: 0, favorites: 0,
views: 0 views: 0,
newCellId: undefined
}, },
isFavorite: false, isFavorite: false,
downloadButtonText: "Download", downloadButtonText: "Download",
@ -45,7 +46,8 @@ describe("NotebookMetadataComponent", () => {
isSample: false, isSample: false,
downloads: 0, downloads: 0,
favorites: 0, favorites: 0,
views: 0 views: 0,
newCellId: undefined
}, },
isFavorite: true, isFavorite: true,
downloadButtonText: "Download", downloadButtonText: "Download",

View File

@ -19,6 +19,7 @@ import { DialogComponent, DialogProps } from "../DialogReactComponent/DialogComp
import { NotebookMetadataComponent } from "./NotebookMetadataComponent"; import { NotebookMetadataComponent } from "./NotebookMetadataComponent";
import "./NotebookViewerComponent.less"; import "./NotebookViewerComponent.less";
import Explorer from "../../Explorer"; import Explorer from "../../Explorer";
import { NotebookV4 } from "@nteract/commutable/lib/v4";
import { SessionStorageUtility } from "../../../Shared/StorageUtility"; import { SessionStorageUtility } from "../../../Shared/StorageUtility";
export interface NotebookViewerComponentProps { export interface NotebookViewerComponentProps {
@ -86,6 +87,7 @@ export class NotebookViewerComponent extends React.Component<
} }
const notebook: Notebook = await response.json(); const notebook: Notebook = await response.json();
this.removeNotebookViewerLink(notebook, this.props.galleryItem?.newCellId);
this.notebookComponentBootstrapper.setContent("json", notebook); this.notebookComponentBootstrapper.setContent("json", notebook);
this.setState({ content: notebook, showProgressBar: false }); this.setState({ content: notebook, showProgressBar: false });
@ -105,10 +107,21 @@ export class NotebookViewerComponent extends React.Component<
} }
} }
private removeNotebookViewerLink = (notebook: Notebook, newCellId: string): void => {
if (!newCellId) {
return;
}
const notebookV4 = notebook as NotebookV4;
if (notebookV4 && notebookV4.cells[0].source[0].search(newCellId)) {
delete notebookV4.cells[0];
notebook = notebookV4;
}
};
public render(): JSX.Element { public render(): JSX.Element {
return ( return (
<div className="notebookViewerContainer"> <div className="notebookViewerContainer">
{this.props.backNavigationText ? ( {this.props.backNavigationText !== undefined ? (
<Link onClick={this.props.onBackClick}> <Link onClick={this.props.onBackClick}>
<Icon iconName="Back" /> {this.props.backNavigationText} <Icon iconName="Back" /> {this.props.backNavigationText}
</Link> </Link>

View File

@ -15,7 +15,9 @@ import CassandraAddCollectionPane from "./Panes/CassandraAddCollectionPane";
import Database from "./Tree/Database"; import Database from "./Tree/Database";
import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane"; import DeleteCollectionConfirmationPane from "./Panes/DeleteCollectionConfirmationPane";
import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane"; import DeleteDatabaseConfirmationPane from "./Panes/DeleteDatabaseConfirmationPane";
import { readDatabases, readCollection, readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase"; import { readOffers, refreshCachedResources } from "../Common/DocumentClientUtilityBase";
import { readCollection } from "../Common/dataAccess/readCollection";
import { readDatabases } from "../Common/dataAccess/readDatabases";
import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane"; import EditTableEntityPane from "./Panes/Tables/EditTableEntityPane";
import EnvironmentUtility from "../Common/EnvironmentUtility"; import EnvironmentUtility from "../Common/EnvironmentUtility";
import GraphStylingPane from "./Panes/GraphStylingPane"; import GraphStylingPane from "./Panes/GraphStylingPane";
@ -203,11 +205,15 @@ export default class Explorer {
public setupNotebooksPane: SetupNotebooksPane; public setupNotebooksPane: SetupNotebooksPane;
public gitHubReposPane: ContextualPaneBase; public gitHubReposPane: ContextualPaneBase;
public publishNotebookPaneAdapter: ReactAdapter; public publishNotebookPaneAdapter: ReactAdapter;
public copyNotebookPaneAdapter: ReactAdapter;
// features // features
public isGalleryPublishEnabled: ko.Computed<boolean>; public isGalleryPublishEnabled: ko.Computed<boolean>;
public isCodeOfConductEnabled: ko.Computed<boolean>;
public isLinkInjectionEnabled: ko.Computed<boolean>;
public isGitHubPaneEnabled: ko.Observable<boolean>; public isGitHubPaneEnabled: ko.Observable<boolean>;
public isPublishNotebookPaneEnabled: ko.Observable<boolean>; public isPublishNotebookPaneEnabled: ko.Observable<boolean>;
public isCopyNotebookPaneEnabled: ko.Observable<boolean>;
public isHostedDataExplorerEnabled: ko.Computed<boolean>; public isHostedDataExplorerEnabled: ko.Computed<boolean>;
public isRightPanelV2Enabled: ko.Computed<boolean>; public isRightPanelV2Enabled: ko.Computed<boolean>;
public canExceedMaximumValue: ko.Computed<boolean>; public canExceedMaximumValue: ko.Computed<boolean>;
@ -408,8 +414,15 @@ export default class Explorer {
this.isGalleryPublishEnabled = ko.computed<boolean>(() => this.isGalleryPublishEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableGalleryPublish) this.isFeatureEnabled(Constants.Features.enableGalleryPublish)
); );
this.isCodeOfConductEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableCodeOfConduct)
);
this.isLinkInjectionEnabled = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.enableLinkInjection)
);
this.isGitHubPaneEnabled = ko.observable<boolean>(false); this.isGitHubPaneEnabled = ko.observable<boolean>(false);
this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false); this.isPublishNotebookPaneEnabled = ko.observable<boolean>(false);
this.isCopyNotebookPaneEnabled = ko.observable<boolean>(false);
this.canExceedMaximumValue = ko.computed<boolean>(() => this.canExceedMaximumValue = ko.computed<boolean>(() =>
this.isFeatureEnabled(Constants.Features.canExceedMaximumValue) this.isFeatureEnabled(Constants.Features.canExceedMaximumValue)
@ -471,7 +484,13 @@ export default class Explorer {
this.notificationConsoleData = ko.observableArray<ConsoleData>([]); this.notificationConsoleData = ko.observableArray<ConsoleData>([]);
this.defaultExperience = ko.observable<string>(); this.defaultExperience = ko.observable<string>();
this.databaseAccount.subscribe(databaseAccount => { this.databaseAccount.subscribe(databaseAccount => {
this.defaultExperience(DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(databaseAccount)); const defaultExperience: string = DefaultExperienceUtility.getDefaultExperienceFromDatabaseAccount(
databaseAccount
);
this.defaultExperience(defaultExperience);
updateUserContext({
defaultExperience: DefaultExperienceUtility.mapDefaultExperienceStringToEnum(defaultExperience)
});
}); });
this.isPreferredApiDocumentDB = ko.computed(() => { this.isPreferredApiDocumentDB = ko.computed(() => {
@ -1403,7 +1422,7 @@ export default class Explorer {
const refreshDatabases = (offers?: DataModels.Offer[]) => { const refreshDatabases = (offers?: DataModels.Offer[]) => {
this._setLoadingStatusText("Fetching databases..."); this._setLoadingStatusText("Fetching databases...");
readDatabases(null /*options*/).then( readDatabases().then(
(databases: DataModels.Database[]) => { (databases: DataModels.Database[]) => {
this._setLoadingStatusText("Successfully fetched databases."); this._setLoadingStatusText("Successfully fetched databases.");
TelemetryProcessor.traceSuccess( TelemetryProcessor.traceSuccess(
@ -1719,6 +1738,7 @@ export default class Explorer {
return; return;
} }
let clearMessage;
try { try {
const notebookWorkspace = await this.notebookWorkspaceManager.getNotebookWorkspaceAsync( const notebookWorkspace = await this.notebookWorkspaceManager.getNotebookWorkspaceAsync(
this.databaseAccount().id, this.databaseAccount().id,
@ -1730,10 +1750,14 @@ export default class Explorer {
notebookWorkspace.properties.status && notebookWorkspace.properties.status &&
notebookWorkspace.properties.status.toLowerCase() === "stopped" notebookWorkspace.properties.status.toLowerCase() === "stopped"
) { ) {
clearMessage = NotificationConsoleUtils.logConsoleProgress("Initializing notebook workspace");
await this.notebookWorkspaceManager.startNotebookWorkspaceAsync(this.databaseAccount().id, "default"); await this.notebookWorkspaceManager.startNotebookWorkspaceAsync(this.databaseAccount().id, "default");
} }
} catch (error) { } catch (error) {
Logger.logError(error, "Explorer/ensureNotebookWorkspaceRunning"); Logger.logError(error, "Explorer/ensureNotebookWorkspaceRunning");
NotificationConsoleUtils.logConsoleError(`Failed to initialize notebook workspace: ${JSON.stringify(error)}`);
} finally {
clearMessage && clearMessage();
} }
} }
@ -2292,7 +2316,7 @@ export default class Explorer {
return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId); return _.find(offers, (offer: DataModels.Offer) => offer.resource === resourceId);
} }
private uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> { public uploadFile(name: string, content: string, parent: NotebookContentItem): Promise<NotebookContentItem> {
if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) { if (!this.isNotebookEnabled() || !this.notebookManager?.notebookContentClient) {
const error = "Attempt to upload notebook, but notebook is not enabled"; const error = "Attempt to upload notebook, but notebook is not enabled";
Logger.logError(error, "Explorer/uploadFile"); Logger.logError(error, "Explorer/uploadFile");
@ -2347,14 +2371,28 @@ export default class Explorer {
return Promise.resolve(false); return Promise.resolve(false);
} }
public publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): void { public async publishNotebook(name: string, content: string | unknown, parentDomElement: HTMLElement): Promise<void> {
if (this.notebookManager) { if (this.notebookManager) {
this.notebookManager.openPublishNotebookPane(name, content, parentDomElement); await this.notebookManager.openPublishNotebookPane(
name,
content,
parentDomElement,
this.isCodeOfConductEnabled(),
this.isLinkInjectionEnabled()
);
this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter; this.publishNotebookPaneAdapter = this.notebookManager.publishNotebookPaneAdapter;
this.isPublishNotebookPaneEnabled(true); this.isPublishNotebookPaneEnabled(true);
} }
} }
public copyNotebook(name: string, content: string): void {
if (this.notebookManager) {
this.notebookManager.openCopyNotebookPane(name, content);
this.copyNotebookPaneAdapter = this.notebookManager.copyNotebookPaneAdapter;
this.isCopyNotebookPaneEnabled(true);
}
}
public showOkModalDialog(title: string, msg: string): void { public showOkModalDialog(title: string, msg: string): void {
this._dialogProps({ this._dialogProps({
isModal: true, isModal: true,
@ -2711,6 +2749,7 @@ export default class Explorer {
} }
await this.resourceTree.initialize(); await this.resourceTree.initialize();
this.notebookManager?.refreshPinnedRepos();
if (this.notebookToImport) { if (this.notebookToImport) {
this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content); this.importAndOpenContent(this.notebookToImport.name, this.notebookToImport.content);
} }

View File

@ -89,7 +89,7 @@ export class NotebookContentClient {
throw new Error(`Parent must be a directory: ${parent}`); throw new Error(`Parent must be a directory: ${parent}`);
} }
const filepath = `${parent.path}/${name}`; const filepath = NotebookUtil.getFilePath(parent.path, name);
if (await this.checkIfFilepathExists(filepath)) { if (await this.checkIfFilepathExists(filepath)) {
throw new Error(`File already exists: ${filepath}`); throw new Error(`File already exists: ${filepath}`);
} }
@ -116,12 +116,7 @@ export class NotebookContentClient {
} }
private async checkIfFilepathExists(filepath: string): Promise<boolean> { private async checkIfFilepathExists(filepath: string): Promise<boolean> {
const basename = filepath.split("/").pop(); const parentDirPath = NotebookUtil.getParentPath(filepath);
let parentDirPath = filepath
.split(basename)
.shift()
.replace(/\/$/, ""); // no trailling slash
const items = await this.fetchNotebookFiles(parentDirPath); const items = await this.fetchNotebookFiles(parentDirPath);
return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath)); return items.some(value => FileSystemUtil.isPathEqual(value.path, filepath));
} }

View File

@ -25,6 +25,7 @@ import { getFullName } from "../../Utils/UserUtils";
import { ImmutableNotebook } from "@nteract/commutable"; import { ImmutableNotebook } from "@nteract/commutable";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { ContextualPaneBase } from "../Panes/ContextualPaneBase"; import { ContextualPaneBase } from "../Panes/ContextualPaneBase";
import { CopyNotebookPaneAdapter } from "../Panes/CopyNotebookPane";
export interface NotebookManagerOptions { export interface NotebookManagerOptions {
container: Explorer; container: Explorer;
@ -49,6 +50,7 @@ export default class NotebookManager {
public gitHubReposPane: ContextualPaneBase; public gitHubReposPane: ContextualPaneBase;
public publishNotebookPaneAdapter: PublishNotebookPaneAdapter; public publishNotebookPaneAdapter: PublishNotebookPaneAdapter;
public copyNotebookPaneAdapter: CopyNotebookPaneAdapter;
public initialize(params: NotebookManagerOptions): void { public initialize(params: NotebookManagerOptions): void {
this.params = params; this.params = params;
@ -90,6 +92,12 @@ export default class NotebookManager {
this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient); this.publishNotebookPaneAdapter = new PublishNotebookPaneAdapter(this.params.container, this.junoClient);
} }
this.copyNotebookPaneAdapter = new CopyNotebookPaneAdapter(
this.params.container,
this.junoClient,
this.gitHubOAuthService
);
this.gitHubOAuthService.getTokenObservable().subscribe(token => { this.gitHubOAuthService.getTokenObservable().subscribe(token => {
this.gitHubClient.setToken(token?.access_token); this.gitHubClient.setToken(token?.access_token);
@ -108,12 +116,29 @@ export default class NotebookManager {
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope); this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
} }
public openPublishNotebookPane( public refreshPinnedRepos(): void {
this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
}
public async openPublishNotebookPane(
name: string, name: string,
content: string | ImmutableNotebook, content: string | ImmutableNotebook,
parentDomElement: HTMLElement parentDomElement: HTMLElement,
): void { isCodeOfConductEnabled: boolean,
this.publishNotebookPaneAdapter.open(name, getFullName(), content, parentDomElement); isLinkInjectionEnabled: boolean
): Promise<void> {
await this.publishNotebookPaneAdapter.open(
name,
getFullName(),
content,
parentDomElement,
isCodeOfConductEnabled,
isLinkInjectionEnabled
);
}
public openCopyNotebookPane(name: string, content: string): void {
this.copyNotebookPaneAdapter.open(name, content);
} }
// Octokit's error handler uses any // Octokit's error handler uses any

View File

@ -13,8 +13,10 @@ import { List, Map } from "immutable";
const fileName = "file"; const fileName = "file";
const notebookName = "file.ipynb"; const notebookName = "file.ipynb";
const filePath = `folder/${fileName}`; const folderPath = "folder";
const notebookPath = `folder/${notebookName}`; const filePath = `${folderPath}/${fileName}`;
const notebookPath = `${folderPath}/${notebookName}`;
const gitHubFolderUri = GitHubUtils.toContentUri("owner", "repo", "branch", folderPath);
const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath); const gitHubFileUri = GitHubUtils.toContentUri("owner", "repo", "branch", filePath);
const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath); const gitHubNotebookUri = GitHubUtils.toContentUri("owner", "repo", "branch", notebookPath);
const notebookRecord = makeNotebookRecord({ const notebookRecord = makeNotebookRecord({
@ -43,10 +45,8 @@ const notebookRecord = makeNotebookRecord({
source: 'display(HTML("<h1>Sample html</h1>"))', source: 'display(HTML("<h1>Sample html</h1>"))',
outputs: List.of({ outputs: List.of({
data: Object.freeze({ data: Object.freeze({
data: {
"text/html": "<h1>Sample output</h1>", "text/html": "<h1>Sample output</h1>",
"text/plain": "<IPython.core.display.HTML object>" "text/plain": "<IPython.core.display.HTML object>"
}
} as MediaBundle), } as MediaBundle),
output_type: "display_data", output_type: "display_data",
metadata: undefined metadata: undefined
@ -82,6 +82,26 @@ describe("NotebookUtil", () => {
}); });
}); });
describe("getFilePath", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getFilePath(folderPath, fileName)).toEqual(filePath);
});
it("works for github file uris", () => {
expect(NotebookUtil.getFilePath(gitHubFolderUri, fileName)).toEqual(gitHubFileUri);
});
});
describe("getParentPath", () => {
it("works for jupyter file paths", () => {
expect(NotebookUtil.getParentPath(filePath)).toEqual(folderPath);
});
it("works for github file uris", () => {
expect(NotebookUtil.getParentPath(gitHubFileUri)).toEqual(gitHubFolderUri);
});
});
describe("getName", () => { describe("getName", () => {
it("works for jupyter file paths", () => { it("works for jupyter file paths", () => {
expect(NotebookUtil.getName(filePath)).toEqual(fileName); expect(NotebookUtil.getName(filePath)).toEqual(fileName);

View File

@ -1,5 +1,5 @@
import path from "path"; import path from "path";
import { ImmutableNotebook, ImmutableCodeCell, ImmutableOutput } from "@nteract/commutable"; import { ImmutableNotebook, ImmutableCodeCell } from "@nteract/commutable";
import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "./NotebookContentItem";
import { StringUtils } from "../../Utils/StringUtils"; import { StringUtils } from "../../Utils/StringUtils";
import * as GitHubUtils from "../../Utils/GitHubUtils"; import * as GitHubUtils from "../../Utils/GitHubUtils";
@ -70,6 +70,46 @@ export class NotebookUtil {
}; };
} }
public static getFilePath(path: string, fileName: string): string {
const contentInfo = GitHubUtils.fromContentUri(path);
if (contentInfo) {
let path = fileName;
if (contentInfo.path) {
path = `${contentInfo.path}/${path}`;
}
return GitHubUtils.toContentUri(contentInfo.owner, contentInfo.repo, contentInfo.branch, path);
}
return `${path}/${fileName}`;
}
public static getParentPath(filepath: string): undefined | string {
const basename = NotebookUtil.getName(filepath);
if (basename) {
const contentInfo = GitHubUtils.fromContentUri(filepath);
if (contentInfo) {
const parentPath = contentInfo.path.split(basename).shift();
if (parentPath === undefined) {
return undefined;
}
return GitHubUtils.toContentUri(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
parentPath.replace(/\/$/, "") // no trailling slash
);
}
const parentPath = filepath.split(basename).shift();
if (parentPath) {
return parentPath.replace(/\/$/, ""); // no trailling slash
}
}
return undefined;
}
public static getName(path: string): undefined | string { public static getName(path: string): undefined | string {
let relativePath: string = path; let relativePath: string = path;
const contentInfo = GitHubUtils.fromContentUri(path); const contentInfo = GitHubUtils.fromContentUri(path);
@ -102,25 +142,19 @@ export class NotebookUtil {
} }
public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number { public static findFirstCodeCellWithDisplay(notebookObject: ImmutableNotebook): number {
let codeCellCount = -1; let codeCellIndex = 0;
for (let i = 0; i < notebookObject.cellOrder.size; i++) { for (let i = 0; i < notebookObject.cellOrder.size; i++) {
const cellId = notebookObject.cellOrder.get(i); const cellId = notebookObject.cellOrder.get(i);
if (cellId) { if (cellId) {
const cell = notebookObject.cellMap.get(cellId); const cell = notebookObject.cellMap.get(cellId);
if (cell && cell.cell_type === "code") { if (cell?.cell_type === "code") {
codeCellCount++; const displayOutput = (cell as ImmutableCodeCell)?.outputs?.find(
const codeCell = cell as ImmutableCodeCell; output => output.output_type === "display_data" || output.output_type === "execute_result"
if (codeCell.outputs) { );
const displayOutput = codeCell.outputs.find((output: ImmutableOutput) => {
if (output.output_type === "display_data" || output.output_type === "execute_result") {
return true;
}
return false;
});
if (displayOutput) { if (displayOutput) {
return codeCellCount; return codeCellIndex;
}
} }
codeCellIndex++;
} }
} }
} }

View File

@ -0,0 +1,198 @@
import ko from "knockout";
import * as React from "react";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import * as Logger from "../../Common/Logger";
import { JunoClient, IPinnedRepo } from "../../Juno/JunoClient";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import Explorer from "../Explorer";
import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent";
import { CopyNotebookPaneComponent, CopyNotebookPaneProps } from "./CopyNotebookPaneComponent";
import { IDropdownOption } from "office-ui-fabric-react";
import { GitHubOAuthService } from "../../GitHub/GitHubOAuthService";
import { HttpStatusCodes } from "../../Common/Constants";
import * as GitHubUtils from "../../Utils/GitHubUtils";
import { NotebookContentItemType, NotebookContentItem } from "../Notebook/NotebookContentItem";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
interface Location {
type: "MyNotebooks" | "GitHub";
// GitHub
owner?: string;
repo?: string;
branch?: string;
}
export class CopyNotebookPaneAdapter implements ReactAdapter {
private static readonly BranchNameWhiteSpace = " ";
parameters: ko.Observable<number>;
private isOpened: boolean;
private isExecuting: boolean;
private formError: string;
private formErrorDetail: string;
private name: string;
private content: string;
private pinnedRepos: IPinnedRepo[];
private selectedLocation: Location;
constructor(
private container: Explorer,
private junoClient: JunoClient,
private gitHubOAuthService: GitHubOAuthService
) {
this.parameters = ko.observable(Date.now());
this.reset();
this.triggerRender();
}
public renderComponent(): JSX.Element {
if (!this.isOpened) {
return undefined;
}
const genericPaneProps: GenericRightPaneProps = {
container: this.container,
formError: this.formError,
formErrorDetail: this.formErrorDetail,
id: "copynotebookpane",
isExecuting: this.isExecuting,
title: "Copy notebook",
submitButtonText: "OK",
onClose: () => this.close(),
onSubmit: () => this.submit()
};
const copyNotebookPaneProps: CopyNotebookPaneProps = {
name: this.name,
pinnedRepos: this.pinnedRepos,
onDropDownChange: this.onDropDownChange
};
return (
<GenericRightPaneComponent {...genericPaneProps}>
<CopyNotebookPaneComponent {...copyNotebookPaneProps} />
</GenericRightPaneComponent>
);
}
public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now()));
}
public async open(name: string, content: string): Promise<void> {
this.name = name;
this.content = content;
this.isOpened = true;
this.triggerRender();
if (this.gitHubOAuthService.isLoggedIn()) {
const response = await this.junoClient.getPinnedRepos(this.gitHubOAuthService.getTokenObservable()()?.scope);
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
const message = `Received HTTP ${response.status} when fetching pinned repos`;
Logger.logError(message, "CopyNotebookPaneAdapter/submit");
NotificationConsoleUtils.logConsoleError(message);
}
if (response.data?.length > 0) {
this.pinnedRepos = response.data;
this.triggerRender();
}
}
}
public close(): void {
this.reset();
this.triggerRender();
}
public async submit(): Promise<void> {
let destination: string = this.selectedLocation?.type;
let clearMessage: () => void;
this.isExecuting = true;
this.triggerRender();
try {
if (!this.selectedLocation) {
throw new Error(`No location selected`);
}
if (this.selectedLocation.type === "GitHub") {
destination = `${destination} - ${GitHubUtils.toRepoFullName(
this.selectedLocation.owner,
this.selectedLocation.repo
)} - ${this.selectedLocation.branch}`;
}
clearMessage = NotificationConsoleUtils.logConsoleProgress(`Copying ${this.name} to ${destination}`);
const notebookContentItem = await this.copyNotebook(this.selectedLocation);
if (!notebookContentItem) {
throw new Error(`Failed to upload ${this.name}`);
}
NotificationConsoleUtils.logConsoleInfo(`Successfully copied ${this.name} to ${destination}`);
} catch (error) {
this.formError = `Failed to copy ${this.name} to ${destination}`;
this.formErrorDetail = `${error}`;
const message = `${this.formError}: ${this.formErrorDetail}`;
Logger.logError(message, "CopyNotebookPaneAdapter/submit");
NotificationConsoleUtils.logConsoleError(message);
return;
} finally {
clearMessage && clearMessage();
this.isExecuting = false;
this.triggerRender();
}
this.close();
}
private copyNotebook = async (location: Location): Promise<NotebookContentItem> => {
let parent: NotebookContentItem;
switch (location.type) {
case "MyNotebooks":
parent = {
name: ResourceTreeAdapter.MyNotebooksTitle,
path: this.container.getNotebookBasePath(),
type: NotebookContentItemType.Directory
};
break;
case "GitHub":
parent = {
name: ResourceTreeAdapter.GitHubReposTitle,
path: GitHubUtils.toContentUri(
this.selectedLocation.owner,
this.selectedLocation.repo,
this.selectedLocation.branch,
""
),
type: NotebookContentItemType.Directory
};
break;
default:
throw new Error(`Unsupported location type ${location.type}`);
}
return this.container.uploadFile(this.name, this.content, parent);
};
private onDropDownChange = (_: React.FormEvent<HTMLDivElement>, option?: IDropdownOption): void => {
this.selectedLocation = option?.data;
};
private reset = (): void => {
this.isOpened = false;
this.isExecuting = false;
this.formError = undefined;
this.formErrorDetail = undefined;
this.name = undefined;
this.content = undefined;
this.pinnedRepos = undefined;
this.selectedLocation = undefined;
};
}

View File

@ -0,0 +1,119 @@
import * as GitHubUtils from "../../Utils/GitHubUtils";
import * as React from "react";
import { IPinnedRepo } from "../../Juno/JunoClient";
import { ResourceTreeAdapter } from "../Tree/ResourceTreeAdapter";
import {
Stack,
Label,
Text,
Dropdown,
IDropdownProps,
IDropdownOption,
SelectableOptionMenuItemType,
IRenderFunction,
ISelectableOption
} from "office-ui-fabric-react";
interface Location {
type: "MyNotebooks" | "GitHub";
// GitHub
owner?: string;
repo?: string;
branch?: string;
}
export interface CopyNotebookPaneProps {
name: string;
pinnedRepos: IPinnedRepo[];
onDropDownChange: (_: React.FormEvent<HTMLDivElement>, option?: IDropdownOption) => void;
}
export class CopyNotebookPaneComponent extends React.Component<CopyNotebookPaneProps> {
private static readonly BranchNameWhiteSpace = " ";
public render(): JSX.Element {
const dropDownProps: IDropdownProps = {
label: "Location",
ariaLabel: "Location",
placeholder: "Select an option",
onRenderTitle: this.onRenderDropDownTitle,
onRenderOption: this.onRenderDropDownOption,
options: this.getDropDownOptions(),
onChange: this.props.onDropDownChange
};
return (
<div className="paneMainContent">
<Stack tokens={{ childrenGap: 10 }}>
<Stack.Item>
<Label htmlFor="notebookName">Name</Label>
<Text id="notebookName">{this.props.name}</Text>
</Stack.Item>
<Dropdown {...dropDownProps} />
</Stack>
</div>
);
}
private onRenderDropDownTitle: IRenderFunction<IDropdownOption[]> = (options: IDropdownOption[]): JSX.Element => {
return <span>{options.length && options[0].title}</span>;
};
private onRenderDropDownOption: IRenderFunction<ISelectableOption> = (option: ISelectableOption): JSX.Element => {
return <span style={{ whiteSpace: "pre-wrap" }}>{option.text}</span>;
};
private getDropDownOptions = (): IDropdownOption[] => {
const options: IDropdownOption[] = [];
options.push({
key: "MyNotebooks-Item",
text: ResourceTreeAdapter.MyNotebooksTitle,
title: ResourceTreeAdapter.MyNotebooksTitle,
data: {
type: "MyNotebooks"
} as Location
});
if (this.props.pinnedRepos && this.props.pinnedRepos.length > 0) {
options.push({
key: "GitHub-Header-Divider",
text: undefined,
itemType: SelectableOptionMenuItemType.Divider
});
options.push({
key: "GitHub-Header",
text: ResourceTreeAdapter.GitHubReposTitle,
itemType: SelectableOptionMenuItemType.Header
});
this.props.pinnedRepos.forEach(pinnedRepo => {
const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name);
options.push({
key: `GitHub-Repo-${repoFullName}`,
text: repoFullName,
disabled: true
});
pinnedRepo.branches.forEach(branch =>
options.push({
key: `GitHub-Repo-${repoFullName}-${branch.name}`,
text: `${CopyNotebookPaneComponent.BranchNameWhiteSpace}${branch.name}`,
title: `${repoFullName} - ${branch.name}`,
data: {
type: "GitHub",
owner: pinnedRepo.owner,
repo: pinnedRepo.name,
branch: branch.name
} as Location
})
);
});
}
return options;
};
}

View File

@ -8,7 +8,6 @@ import Explorer from "../Explorer";
export interface GenericRightPaneProps { export interface GenericRightPaneProps {
container: Explorer; container: Explorer;
content: JSX.Element;
formError: string; formError: string;
formErrorDetail: string; formErrorDetail: string;
id: string; id: string;
@ -17,6 +16,7 @@ export interface GenericRightPaneProps {
onSubmit: () => void; onSubmit: () => void;
submitButtonText: string; submitButtonText: string;
title: string; title: string;
isSubmitButtonVisible?: boolean;
} }
export interface GenericRightPaneState { export interface GenericRightPaneState {
@ -56,18 +56,18 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
> >
<div className="panelContentWrapper"> <div className="panelContentWrapper">
{this.createPanelHeader()} {this.renderPanelHeader()}
{this.createErrorSection()} {this.renderErrorSection()}
{this.props.content} {this.props.children}
{this.createPanelFooter()} {this.renderPanelFooter()}
</div> </div>
{this.createLoadingScreen()} {this.renderLoadingScreen()}
</div> </div>
</div> </div>
); );
} }
private createPanelHeader = (): JSX.Element => { private renderPanelHeader = (): JSX.Element => {
return ( return (
<div className="firstdivbg headerline"> <div className="firstdivbg headerline">
<span id="databaseTitle">{this.props.title}</span> <span id="databaseTitle">{this.props.title}</span>
@ -83,7 +83,7 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
); );
}; };
private createErrorSection = (): JSX.Element => { private renderErrorSection = (): JSX.Element => {
return ( return (
<div className="warningErrorContainer" aria-live="assertive" hidden={!this.props.formError}> <div className="warningErrorContainer" aria-live="assertive" hidden={!this.props.formError}>
<div className="warningErrorContent"> <div className="warningErrorContent">
@ -103,11 +103,12 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
); );
}; };
private createPanelFooter = (): JSX.Element => { private renderPanelFooter = (): JSX.Element => {
return ( return (
<div className="paneFooter"> <div className="paneFooter">
<div className="leftpanel-okbut"> <div className="leftpanel-okbut">
<PrimaryButton <PrimaryButton
style={{ visibility: this.props.isSubmitButtonVisible ? "visible" : "hidden" }}
ariaLabel="Submit" ariaLabel="Submit"
title="Submit" title="Submit"
onClick={this.props.onSubmit} onClick={this.props.onSubmit}
@ -120,7 +121,7 @@ export class GenericRightPaneComponent extends React.Component<GenericRightPaneP
); );
}; };
private createLoadingScreen = (): JSX.Element => { private renderLoadingScreen = (): JSX.Element => {
return ( return (
<div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.props.isExecuting}> <div className="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" hidden={!this.props.isExecuting}>
<img className="dataExplorerLoader" src={LoadingIndicatorIcon} /> <img className="dataExplorerLoader" src={LoadingIndicatorIcon} />

View File

@ -10,6 +10,8 @@ import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRight
import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent"; import { PublishNotebookPaneComponent, PublishNotebookPaneProps } from "./PublishNotebookPaneComponent";
import { ImmutableNotebook } from "@nteract/commutable/src"; import { ImmutableNotebook } from "@nteract/commutable/src";
import { toJS } from "@nteract/commutable"; import { toJS } from "@nteract/commutable";
import { CodeOfConductComponent } from "../Controls/NotebookGallery/CodeOfConductComponent";
import { HttpStatusCodes } from "../../Common/Constants";
export class PublishNotebookPaneAdapter implements ReactAdapter { export class PublishNotebookPaneAdapter implements ReactAdapter {
parameters: ko.Observable<number>; parameters: ko.Observable<number>;
@ -26,6 +28,8 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
private imageSrc: string; private imageSrc: string;
private notebookObject: ImmutableNotebook; private notebookObject: ImmutableNotebook;
private parentDomElement: HTMLElement; private parentDomElement: HTMLElement;
private isCodeOfConductAccepted: boolean;
private isLinkInjectionEnabled: boolean;
constructor(private container: Explorer, private junoClient: JunoClient) { constructor(private container: Explorer, private junoClient: JunoClient) {
this.parameters = ko.observable(Date.now()); this.parameters = ko.observable(Date.now());
@ -40,7 +44,6 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
const props: GenericRightPaneProps = { const props: GenericRightPaneProps = {
container: this.container, container: this.container,
content: this.createContent(),
formError: this.formError, formError: this.formError,
formErrorDetail: this.formErrorDetail, formErrorDetail: this.formErrorDetail,
id: "publishnotebookpane", id: "publishnotebookpane",
@ -48,33 +51,86 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
title: "Publish to gallery", title: "Publish to gallery",
submitButtonText: "Publish", submitButtonText: "Publish",
onClose: () => this.close(), onClose: () => this.close(),
onSubmit: () => this.submit() onSubmit: () => this.submit(),
isSubmitButtonVisible: this.isCodeOfConductAccepted
}; };
return <GenericRightPaneComponent {...props} />; const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookName: this.name,
notebookDescription: "",
notebookTags: "",
notebookAuthor: this.author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: this.notebookObject,
notebookParentDomElement: this.parentDomElement,
onChangeName: (newValue: string) => (this.name = newValue),
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
clearFormError: this.clearFormError
};
return (
<GenericRightPaneComponent {...props}>
{!this.isCodeOfConductAccepted ? (
<div style={{ padding: "15px", marginTop: "10px" }}>
<CodeOfConductComponent
junoClient={this.junoClient}
onAcceptCodeOfConduct={() => {
this.isCodeOfConductAccepted = true;
this.triggerRender();
}}
/>
</div>
) : (
<PublishNotebookPaneComponent {...publishNotebookPaneProps} />
)}
</GenericRightPaneComponent>
);
} }
public triggerRender(): void { public triggerRender(): void {
window.requestAnimationFrame(() => this.parameters(Date.now())); window.requestAnimationFrame(() => this.parameters(Date.now()));
} }
public open( public async open(
name: string, name: string,
author: string, author: string,
notebookContent: string | ImmutableNotebook, notebookContent: string | ImmutableNotebook,
parentDomElement: HTMLElement parentDomElement: HTMLElement,
): void { isCodeOfConductEnabled: boolean,
isLinkInjectionEnabled: boolean
): Promise<void> {
if (isCodeOfConductEnabled) {
try {
const response = await this.junoClient.isCodeOfConductAccepted();
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
throw new Error(`Received HTTP ${response.status} when accepting code of conduct`);
}
this.isCodeOfConductAccepted = response.data;
} catch (error) {
const message = `Failed to check if code of conduct was accepted: ${error}`;
Logger.logError(message, "PublishNotebookPaneAdapter/isCodeOfConductAccepted");
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
}
} else {
this.isCodeOfConductAccepted = true;
}
this.name = name; this.name = name;
this.author = author; this.author = author;
if (typeof notebookContent === "string") { if (typeof notebookContent === "string") {
this.content = notebookContent as string; this.content = notebookContent as string;
} else { } else {
this.content = JSON.stringify(toJS(notebookContent as ImmutableNotebook)); this.content = JSON.stringify(toJS(notebookContent));
this.notebookObject = notebookContent; this.notebookObject = notebookContent;
} }
this.parentDomElement = parentDomElement; this.parentDomElement = parentDomElement;
this.isOpened = true; this.isOpened = true;
this.isLinkInjectionEnabled = isLinkInjectionEnabled;
this.triggerRender(); this.triggerRender();
} }
@ -102,13 +158,12 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.tags?.split(","), this.tags?.split(","),
this.author, this.author,
this.imageSrc, this.imageSrc,
this.content this.content,
this.isLinkInjectionEnabled
); );
if (!response.data) { if (response.data) {
throw new Error(`Received HTTP ${response.status} when publishing ${name} to gallery`);
}
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`); NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Info, `Published ${name} to gallery`);
}
} catch (error) { } catch (error) {
this.formError = `Failed to publish ${this.name} to gallery`; this.formError = `Failed to publish ${this.name} to gallery`;
this.formErrorDetail = `${error}`; this.formErrorDetail = `${error}`;
@ -142,25 +197,6 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.triggerRender(); this.triggerRender();
}; };
private createContent = (): JSX.Element => {
const publishNotebookPaneProps: PublishNotebookPaneProps = {
notebookName: this.name,
notebookDescription: "",
notebookTags: "",
notebookAuthor: this.author,
notebookCreatedDate: new Date().toISOString(),
notebookObject: this.notebookObject,
notebookParentDomElement: this.parentDomElement,
onChangeDescription: (newValue: string) => (this.description = newValue),
onChangeTags: (newValue: string) => (this.tags = newValue),
onChangeImageSrc: (newValue: string) => (this.imageSrc = newValue),
onError: this.createFormErrorForLargeImageSelection,
clearFormError: this.clearFormError
};
return <PublishNotebookPaneComponent {...publishNotebookPaneProps} />;
};
private reset = (): void => { private reset = (): void => {
this.isOpened = false; this.isOpened = false;
this.isExecuting = false; this.isExecuting = false;
@ -174,5 +210,7 @@ export class PublishNotebookPaneAdapter implements ReactAdapter {
this.imageSrc = undefined; this.imageSrc = undefined;
this.notebookObject = undefined; this.notebookObject = undefined;
this.parentDomElement = undefined; this.parentDomElement = undefined;
this.isCodeOfConductAccepted = undefined;
this.isLinkInjectionEnabled = undefined;
}; };
} }

View File

@ -12,6 +12,7 @@ describe("PublishNotebookPaneComponent", () => {
notebookCreatedDate: "2020-07-17T00:00:00Z", notebookCreatedDate: "2020-07-17T00:00:00Z",
notebookObject: undefined, notebookObject: undefined,
notebookParentDomElement: undefined, notebookParentDomElement: undefined,
onChangeName: undefined,
onChangeDescription: undefined, onChangeDescription: undefined,
onChangeTags: undefined, onChangeTags: undefined,
onChangeImageSrc: undefined, onChangeImageSrc: undefined,

View File

@ -15,6 +15,7 @@ export interface PublishNotebookPaneProps {
notebookCreatedDate: string; notebookCreatedDate: string;
notebookObject: ImmutableNotebook; notebookObject: ImmutableNotebook;
notebookParentDomElement: HTMLElement; notebookParentDomElement: HTMLElement;
onChangeName: (newValue: string) => void;
onChangeDescription: (newValue: string) => void; onChangeDescription: (newValue: string) => void;
onChangeTags: (newValue: string) => void; onChangeTags: (newValue: string) => void;
onChangeImageSrc: (newValue: string) => void; onChangeImageSrc: (newValue: string) => void;
@ -24,6 +25,7 @@ export interface PublishNotebookPaneProps {
interface PublishNotebookPaneState { interface PublishNotebookPaneState {
type: string; type: string;
notebookName: string;
notebookDescription: string; notebookDescription: string;
notebookTags: string; notebookTags: string;
imageSrc: string; imageSrc: string;
@ -40,6 +42,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
private static readonly maxImageSizeInMib = 1.5; private static readonly maxImageSizeInMib = 1.5;
private descriptionPara1: string; private descriptionPara1: string;
private descriptionPara2: string; private descriptionPara2: string;
private nameProps: ITextFieldProps;
private descriptionProps: ITextFieldProps; private descriptionProps: ITextFieldProps;
private tagsProps: ITextFieldProps; private tagsProps: ITextFieldProps;
private thumbnailUrlProps: ITextFieldProps; private thumbnailUrlProps: ITextFieldProps;
@ -52,6 +55,7 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
this.state = { this.state = {
type: ImageTypes.Url, type: ImageTypes.Url,
notebookName: props.notebookName,
notebookDescription: "", notebookDescription: "",
notebookTags: "", notebookTags: "",
imageSrc: undefined imageSrc: undefined
@ -165,6 +169,17 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
} }
}; };
this.nameProps = {
label: "Name",
ariaLabel: "Name",
defaultValue: this.props.notebookName,
required: true,
onChange: (event, newValue) => {
this.props.onChangeName(newValue);
this.setState({ notebookName: newValue });
}
};
this.descriptionProps = { this.descriptionProps = {
label: "Description", label: "Description",
ariaLabel: "Description", ariaLabel: "Description",
@ -245,6 +260,10 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
<Text>{this.descriptionPara2}</Text> <Text>{this.descriptionPara2}</Text>
</Stack.Item> </Stack.Item>
<Stack.Item>
<TextField {...this.nameProps} />
</Stack.Item>
<Stack.Item> <Stack.Item>
<TextField {...this.descriptionProps} /> <TextField {...this.descriptionProps} />
</Stack.Item> </Stack.Item>
@ -276,7 +295,8 @@ export class PublishNotebookPaneComponent extends React.Component<PublishNoteboo
isSample: false, isSample: false,
downloads: 0, downloads: 0,
favorites: 0, favorites: 0,
views: 0 views: 0,
newCellId: undefined
}} }}
isFavorite={false} isFavorite={false}
showDownload={true} showDownload={true}

View File

@ -1,15 +1,13 @@
import * as Constants from "../../Common/Constants";
import * as ErrorParserUtility from "../../Common/ErrorParserUtility"; import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
import * as ko from "knockout"; import * as ko from "knockout";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import * as React from "react"; import * as React from "react";
import * as ViewModels from "../../Contracts/ViewModels"; import * as ViewModels from "../../Contracts/ViewModels";
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent"; import { GenericRightPaneComponent, GenericRightPaneProps } from "./GenericRightPaneComponent";
import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils";
import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler";
import { UploadDetailsRecord, UploadDetails } from "../../workers/upload/definitions"; import { UploadDetailsRecord, UploadDetails } from "../../workers/upload/definitions";
import InfoBubbleIcon from "../../../images/info-bubble.svg"; import { UploadItemsPaneComponent, UploadItemsPaneProps } from "./UploadItemsPaneComponent";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
const UPLOAD_FILE_SIZE_LIMIT = 2097152; const UPLOAD_FILE_SIZE_LIMIT = 2097152;
@ -35,19 +33,30 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
return undefined; return undefined;
} }
const props: GenericRightPaneProps = { const genericPaneProps: GenericRightPaneProps = {
container: this.container, container: this.container,
content: this.createContent(),
formError: this.formError, formError: this.formError,
formErrorDetail: this.formErrorDetail, formErrorDetail: this.formErrorDetail,
id: "uploaditemspane", id: "uploaditemspane",
isExecuting: this.isExecuting, isExecuting: this.isExecuting,
isSubmitButtonVisible: true,
title: "Upload Items", title: "Upload Items",
submitButtonText: "Upload", submitButtonText: "Upload",
onClose: () => this.close(), onClose: () => this.close(),
onSubmit: () => this.submit() onSubmit: () => this.submit()
}; };
return <GenericRightPaneComponent {...props} />;
const uploadItemsPaneProps: UploadItemsPaneProps = {
selectedFilesTitle: this.selectedFilesTitle,
updateSelectedFiles: this.updateSelectedFiles,
uploadFileData: this.uploadFileData
};
return (
<GenericRightPaneComponent {...genericPaneProps}>
<UploadItemsPaneComponent {...uploadItemsPaneProps} />
</GenericRightPaneComponent>
);
} }
public triggerRender(): void { public triggerRender(): void {
@ -110,77 +119,6 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
}); });
} }
private createContent = (): JSX.Element => {
return <div className="panelContent">{this.createMainContentSection()}</div>;
};
private createMainContentSection = (): JSX.Element => {
return (
<div className="paneMainContent">
<div className="renewUploadItemsHeader">
<span> Select JSON Files </span>
<span className="infoTooltip" role="tooltip" tabIndex={0}>
<img className="infoImg" src={InfoBubbleIcon} alt="More information" />
<span className="tooltiptext infoTooltipWidth">
Select one or more JSON files to upload. Each file can contain a single JSON document or an array of JSON
documents. The combined size of all files in an individual upload operation must be less than 2 MB. You
can perform multiple upload operations for larger data sets.
</span>
</span>
</div>
<input
className="importFilesTitle"
type="text"
disabled
value={this.selectedFilesTitle}
aria-label="Select JSON Files"
/>
<input
type="file"
id="importDocsInput"
title="Upload Icon"
multiple
accept="application/json"
role="button"
tabIndex={0}
style={{ display: "none" }}
onChange={this.updateSelectedFiles}
/>
<IconButton
iconProps={{ iconName: "FolderHorizontal" }}
className="fileImportButton"
alt="Select JSON files to upload"
title="Select JSON files to upload"
onClick={this.onImportButtonClick}
onKeyPress={this.onImportButtonKeyPress}
/>
<div className="fileUploadSummaryContainer" hidden={this.uploadFileData.length === 0}>
<b>File upload status</b>
<table className="fileUploadSummary">
<thead>
<tr className="fileUploadSummaryHeader fileUploadSummaryTuple">
<th>FILE NAME</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{this.uploadFileData.map(
(data: UploadDetailsRecord): JSX.Element => {
return (
<tr className="fileUploadSummaryTuple" key={data.fileName}>
<td>{data.fileName}</td>
<td>{this.fileUploadSummaryText(data.numSucceeded, data.numFailed)}</td>
</tr>
);
}
)}
</tbody>
</table>
</div>
</div>
);
};
private updateSelectedFiles = (event: React.ChangeEvent<HTMLInputElement>): void => { private updateSelectedFiles = (event: React.ChangeEvent<HTMLInputElement>): void => {
this.selectedFiles = event.target.files; this.selectedFiles = event.target.files;
this._updateSelectedFilesTitle(); this._updateSelectedFilesTitle();
@ -212,21 +150,6 @@ export class UploadItemsPaneAdapter implements ReactAdapter {
return totalFileSize; return totalFileSize;
} }
private fileUploadSummaryText = (numSucceeded: number, numFailed: number): string => {
return `${numSucceeded} items created, ${numFailed} errors`;
};
private onImportButtonClick = (): void => {
document.getElementById("importDocsInput").click();
};
private onImportButtonKeyPress = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (event.charCode === Constants.KeyCodes.Enter || event.charCode === Constants.KeyCodes.Space) {
this.onImportButtonClick();
event.stopPropagation();
}
};
private reset = (): void => { private reset = (): void => {
this.isOpened = false; this.isOpened = false;
this.isExecuting = false; this.isExecuting = false;

View File

@ -0,0 +1,97 @@
import * as Constants from "../../Common/Constants";
import * as React from "react";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { UploadDetailsRecord } from "../../workers/upload/definitions";
import InfoBubbleIcon from "../../../images/info-bubble.svg";
export interface UploadItemsPaneProps {
selectedFilesTitle: string;
updateSelectedFiles: (event: React.ChangeEvent<HTMLInputElement>) => void;
uploadFileData: UploadDetailsRecord[];
}
export class UploadItemsPaneComponent extends React.Component<UploadItemsPaneProps> {
public render(): JSX.Element {
return (
<div className="panelContent">
<div className="paneMainContent">
<div className="renewUploadItemsHeader">
<span> Select JSON Files </span>
<span className="infoTooltip" role="tooltip" tabIndex={0}>
<img className="infoImg" src={InfoBubbleIcon} alt="More information" />
<span className="tooltiptext infoTooltipWidth">
Select one or more JSON files to upload. Each file can contain a single JSON document or an array of
JSON documents. The combined size of all files in an individual upload operation must be less than 2 MB.
You can perform multiple upload operations for larger data sets.
</span>
</span>
</div>
<input
className="importFilesTitle"
type="text"
disabled
value={this.props.selectedFilesTitle}
aria-label="Select JSON Files"
/>
<input
type="file"
id="importDocsInput"
title="Upload Icon"
multiple
accept="application/json"
role="button"
tabIndex={0}
style={{ display: "none" }}
onChange={this.props.updateSelectedFiles}
/>
<IconButton
iconProps={{ iconName: "FolderHorizontal" }}
className="fileImportButton"
alt="Select JSON files to upload"
title="Select JSON files to upload"
onClick={this.onImportButtonClick}
onKeyPress={this.onImportButtonKeyPress}
/>
<div className="fileUploadSummaryContainer" hidden={this.props.uploadFileData.length === 0}>
<b>File upload status</b>
<table className="fileUploadSummary">
<thead>
<tr className="fileUploadSummaryHeader fileUploadSummaryTuple">
<th>FILE NAME</th>
<th>STATUS</th>
</tr>
</thead>
<tbody>
{this.props.uploadFileData.map(
(data: UploadDetailsRecord): JSX.Element => {
return (
<tr className="fileUploadSummaryTuple" key={data.fileName}>
<td>{data.fileName}</td>
<td>{this.fileUploadSummaryText(data.numSucceeded, data.numFailed)}</td>
</tr>
);
}
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
private fileUploadSummaryText = (numSucceeded: number, numFailed: number): string => {
return `${numSucceeded} items created, ${numFailed} errors`;
};
private onImportButtonClick = (): void => {
document.getElementById("importDocsInput").click();
};
private onImportButtonKeyPress = (event: React.KeyboardEvent<HTMLButtonElement>): void => {
if (event.charCode === Constants.KeyCodes.Enter || event.charCode === Constants.KeyCodes.Space) {
this.onImportButtonClick();
event.stopPropagation();
}
};
}

View File

@ -22,6 +22,15 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
Would you like to publish and share "SampleNotebook" to the gallery? Would you like to publish and share "SampleNotebook" to the gallery?
</Text> </Text>
</StackItem> </StackItem>
<StackItem>
<StyledTextFieldBase
ariaLabel="Name"
defaultValue="SampleNotebook.ipynb"
label="Name"
onChange={[Function]}
required={true}
/>
</StackItem>
<StackItem> <StackItem>
<StyledTextFieldBase <StyledTextFieldBase
ariaLabel="Description" ariaLabel="Description"
@ -93,6 +102,7 @@ exports[`PublishNotebookPaneComponent renders 1`] = `
"id": undefined, "id": undefined,
"isSample": false, "isSample": false,
"name": "SampleNotebook.ipynb", "name": "SampleNotebook.ipynb",
"newCellId": undefined,
"tags": Array [ "tags": Array [
"", "",
], ],

View File

@ -16,9 +16,10 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { PlatformType } from "../../PlatformType"; import { PlatformType } from "../../PlatformType";
import { RequestOptions } from "@azure/cosmos/dist-esm"; import { RequestOptions } from "@azure/cosmos/dist-esm";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { updateOfferThroughputBeyondLimit, updateOffer } from "../../Common/DocumentClientUtilityBase"; import { updateOffer } from "../../Common/DocumentClientUtilityBase";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { updateOfferThroughputBeyondLimit } from "../../Common/dataAccess/updateOfferThroughputBeyondLimit";
const updateThroughputBeyondLimitWarningMessage: string = ` const updateThroughputBeyondLimitWarningMessage: string = `
You are about to request an increase in throughput beyond the pre-allocated capacity. You are about to request an increase in throughput beyond the pre-allocated capacity.
@ -519,16 +520,15 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million && this.maxRUs() <= SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million
) { ) {
const requestPayload: DataModels.UpdateOfferThroughputRequest = { const requestPayload = {
subscriptionId: userContext.subscriptionId, subscriptionId: userContext.subscriptionId,
databaseAccountName: userContext.databaseAccount.name, databaseAccountName: userContext.databaseAccount.name,
resourceGroup: userContext.resourceGroup, resourceGroup: userContext.resourceGroup,
databaseName: this.database.id(), databaseName: this.database.id(),
collectionName: undefined,
throughput: newThroughput, throughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: false offerIsRUPerMinuteThroughputEnabled: false
}; };
const updateOfferBeyondLimitPromise: Q.Promise<void> = updateOfferThroughputBeyondLimit(requestPayload).then( const updateOfferBeyondLimitPromise = updateOfferThroughputBeyondLimit(requestPayload).then(
() => { () => {
this.database.offer().content.offerThroughput = originalThroughputValue; this.database.offer().content.offerThroughput = originalThroughputValue;
this.throughput(originalThroughputValue); this.throughput(originalThroughputValue);
@ -552,7 +552,7 @@ export default class DatabaseSettingsTab extends TabsBase implements ViewModels.
); );
} }
); );
promises.push(updateOfferBeyondLimitPromise); promises.push(Q(updateOfferBeyondLimitPromise));
} else { } else {
const newOffer: DataModels.Offer = { const newOffer: DataModels.Offer = {
content: { content: {

View File

@ -30,6 +30,7 @@ import { configContext } from "../../ConfigContext";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { NotebookContentItem } from "../Notebook/NotebookContentItem"; import { NotebookContentItem } from "../Notebook/NotebookContentItem";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { toJS, stringifyNotebook } from "@nteract/commutable";
export interface NotebookTabOptions extends ViewModels.TabOptions { export interface NotebookTabOptions extends ViewModels.TabOptions {
account: DataModels.DatabaseAccount; account: DataModels.DatabaseAccount;
@ -122,6 +123,7 @@ export default class NotebookTabV2 extends TabsBase {
const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs(); const availableKernels = NotebookTabV2.clientManager.getAvailableKernelSpecs();
const saveLabel = "Save"; const saveLabel = "Save";
const copyToLabel = "Copy to ...";
const publishLabel = "Publish to gallery"; const publishLabel = "Publish to gallery";
const workspaceLabel = "No Workspace"; const workspaceLabel = "No Workspace";
const kernelLabel = "No Kernel"; const kernelLabel = "No Kernel";
@ -164,9 +166,17 @@ export default class NotebookTabV2 extends TabsBase {
disabled: false, disabled: false,
ariaLabel: saveLabel ariaLabel: saveLabel
}, },
{
iconName: "Copy",
onCommandClick: () => this.copyNotebook(),
commandButtonLabel: copyToLabel,
hasPopup: false,
disabled: false,
ariaLabel: copyToLabel
},
{ {
iconName: "PublishContent", iconName: "PublishContent",
onCommandClick: () => this.publishToGallery(), onCommandClick: async () => await this.publishToGallery(),
commandButtonLabel: publishLabel, commandButtonLabel: publishLabel,
hasPopup: false, hasPopup: false,
disabled: false, disabled: false,
@ -456,15 +466,27 @@ export default class NotebookTabV2 extends TabsBase {
); );
} }
private publishToGallery = () => { private publishToGallery = async () => {
const notebookContent = this.notebookComponentAdapter.getContent(); const notebookContent = this.notebookComponentAdapter.getContent();
this.container.publishNotebook( await this.container.publishNotebook(
notebookContent.name, notebookContent.name,
notebookContent.content, notebookContent.content,
this.notebookComponentAdapter.getNotebookParentElement() this.notebookComponentAdapter.getNotebookParentElement()
); );
}; };
private copyNotebook = () => {
const notebookContent = this.notebookComponentAdapter.getContent();
let content: string;
if (typeof notebookContent.content === "string") {
content = notebookContent.content;
} else {
content = stringifyNotebook(toJS(notebookContent.content));
}
this.container.copyNotebook(notebookContent.name, content);
};
private traceTelemetry(actionType: number) { private traceTelemetry(actionType: number) {
TelemetryProcessor.trace(actionType, ActionModifiers.Mark, { TelemetryProcessor.trace(actionType, ActionModifiers.Mark, {
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name, databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,

View File

@ -17,13 +17,10 @@ import { Action } from "../../Shared/Telemetry/TelemetryConstants";
import { PlatformType } from "../../PlatformType"; import { PlatformType } from "../../PlatformType";
import { RequestOptions } from "@azure/cosmos/dist-esm"; import { RequestOptions } from "@azure/cosmos/dist-esm";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { import { updateOffer, updateCollection } from "../../Common/DocumentClientUtilityBase";
updateOfferThroughputBeyondLimit,
updateOffer,
updateCollection
} from "../../Common/DocumentClientUtilityBase";
import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent"; import { CommandButtonComponentProps } from "../Controls/CommandButton/CommandButtonComponent";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
import { updateOfferThroughputBeyondLimit } from "../../Common/dataAccess/updateOfferThroughputBeyondLimit";
const ttlWarning: string = ` const ttlWarning: string = `
The system will automatically delete items based on the TTL value (in seconds) you provide, without needing a delete operation explicitly issued by a client application. The system will automatically delete items based on the TTL value (in seconds) you provide, without needing a delete operation explicitly issued by a client application.
@ -347,7 +344,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
if (!this.isAutoPilotSelected()) { if (!this.isAutoPilotSelected()) {
return false; return false;
} }
const originalAutoPilotSettings = this.collection.offer().content.offerAutopilotSettings; const originalAutoPilotSettings = this.collection?.offer()?.content?.offerAutopilotSettings;
if (!originalAutoPilotSettings) { if (!originalAutoPilotSettings) {
return false; return false;
} }
@ -1144,7 +1141,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
newThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million && newThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs1Million &&
this.container != null this.container != null
) { ) {
const requestPayload: DataModels.UpdateOfferThroughputRequest = { const requestPayload = {
subscriptionId: userContext.subscriptionId, subscriptionId: userContext.subscriptionId,
databaseAccountName: userContext.databaseAccount.name, databaseAccountName: userContext.databaseAccount.name,
resourceGroup: userContext.resourceGroup, resourceGroup: userContext.resourceGroup,
@ -1153,7 +1150,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
throughput: newThroughput, throughput: newThroughput,
offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled offerIsRUPerMinuteThroughputEnabled: isRUPerMinuteThroughputEnabled
}; };
const updateOfferBeyondLimitPromise: Q.Promise<void> = updateOfferThroughputBeyondLimit(requestPayload).then( const updateOfferBeyondLimitPromise = updateOfferThroughputBeyondLimit(requestPayload).then(
() => { () => {
this.collection.offer().content.offerThroughput = originalThroughputValue; this.collection.offer().content.offerThroughput = originalThroughputValue;
this.throughput(originalThroughputValue); this.throughput(originalThroughputValue);
@ -1185,7 +1182,7 @@ export default class SettingsTab extends TabsBase implements ViewModels.WaitsFor
); );
} }
); );
promises.push(updateOfferBeyondLimitPromise); promises.push(Q(updateOfferBeyondLimitPromise));
} else { } else {
const updateOfferPromise = updateOffer(this.collection.offer(), newOffer, headerOptions).then( const updateOfferPromise = updateOffer(this.collection.offer(), newOffer, headerOptions).then(
(updatedOffer: DataModels.Offer) => { (updatedOffer: DataModels.Offer) => {

View File

@ -12,7 +12,8 @@ import * as NotificationConsoleUtils from "../../Utils/NotificationConsoleUtils"
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent"; import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
import * as Logger from "../../Common/Logger"; import * as Logger from "../../Common/Logger";
import Explorer from "../Explorer"; import Explorer from "../Explorer";
import { readCollections, readOffers, readOffer } from "../../Common/DocumentClientUtilityBase"; import { readOffers, readOffer } from "../../Common/DocumentClientUtilityBase";
import { readCollections } from "../../Common/dataAccess/readCollections";
export default class Database implements ViewModels.Database { export default class Database implements ViewModels.Database {
public nodeKind: string; public nodeKind: string;
@ -259,7 +260,7 @@ export default class Database implements ViewModels.Database {
let collectionVMs: Collection[] = []; let collectionVMs: Collection[] = [];
let deferred: Q.Deferred<void> = Q.defer<void>(); let deferred: Q.Deferred<void> = Q.defer<void>();
readCollections(this).then( readCollections(this.id()).then(
(collections: DataModels.Collection[]) => { (collections: DataModels.Collection[]) => {
let collectionsToAddVMPromises: Q.Promise<any>[] = []; let collectionsToAddVMPromises: Q.Promise<any>[] = [];
let deltaCollections = this.getDeltaCollections(collections); let deltaCollections = this.getDeltaCollections(collections);

View File

@ -7,6 +7,7 @@ import * as ViewModels from "../../Contracts/ViewModels";
import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem";
import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory"; import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory";
import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity"; import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity";
import CopyIcon from "../../../images/notebook/Notebook-copy.svg";
import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg";
import CollectionIcon from "../../../images/tree-collection.svg"; import CollectionIcon from "../../../images/tree-collection.svg";
import DeleteIcon from "../../../images/delete.svg"; import DeleteIcon from "../../../images/delete.svg";
@ -33,6 +34,9 @@ import TabsBase from "../Tabs/TabsBase";
import { userContext } from "../../UserContext"; import { userContext } from "../../UserContext";
export class ResourceTreeAdapter implements ReactAdapter { export class ResourceTreeAdapter implements ReactAdapter {
public static readonly MyNotebooksTitle = "My Notebooks";
public static readonly GitHubReposTitle = "GitHub repos";
private static readonly DataTitle = "DATA"; private static readonly DataTitle = "DATA";
private static readonly NotebooksTitle = "NOTEBOOKS"; private static readonly NotebooksTitle = "NOTEBOOKS";
private static readonly PseudoDirPath = "PsuedoDir"; private static readonly PseudoDirPath = "PsuedoDir";
@ -104,7 +108,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
this.myNotebooksContentRoot = { this.myNotebooksContentRoot = {
name: "My Notebooks", name: ResourceTreeAdapter.MyNotebooksTitle,
path: this.container.getNotebookBasePath(), path: this.container.getNotebookBasePath(),
type: NotebookContentItemType.Directory type: NotebookContentItemType.Directory
}; };
@ -118,7 +122,7 @@ export class ResourceTreeAdapter implements ReactAdapter {
if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) {
this.gitHubNotebooksContentRoot = { this.gitHubNotebooksContentRoot = {
name: "GitHub repos", name: ResourceTreeAdapter.GitHubReposTitle,
path: ResourceTreeAdapter.PseudoDirPath, path: ResourceTreeAdapter.PseudoDirPath,
type: NotebookContentItemType.Directory type: NotebookContentItemType.Directory
}; };
@ -563,6 +567,11 @@ export class ResourceTreeAdapter implements ReactAdapter {
); );
} }
}, },
{
label: "Copy to ...",
iconSrc: CopyIcon,
onClick: () => this.copyNotebook(item)
},
{ {
label: "Download", label: "Download",
iconSrc: NotebookIcon, iconSrc: NotebookIcon,
@ -574,6 +583,13 @@ export class ResourceTreeAdapter implements ReactAdapter {
}; };
} }
private copyNotebook = async (item: NotebookContentItem) => {
const content = await this.container.readFile(item);
if (content) {
this.container.copyNotebook(item.name, content);
}
};
private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] { private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] {
let items: TreeNodeMenuItem[] = [ let items: TreeNodeMenuItem[] = [
{ {

View File

@ -317,6 +317,13 @@ export class GitHubClient {
objectExpression: `refs/heads/${branch}:${path || ""}` objectExpression: `refs/heads/${branch}:${path || ""}`
} as ContentsQueryParams)) as ContentsQueryResponse; } as ContentsQueryParams)) as ContentsQueryResponse;
if (!response.repository.object) {
return {
status: HttpStatusCodes.NotFound,
data: undefined
};
}
let data: IGitHubFile | IGitHubFile[]; let data: IGitHubFile | IGitHubFile[];
const entries = response.repository.object.entries; const entries = response.repository.object.entries;
const gitHubRepo = GitHubClient.toGitHubRepo(response.repository); const gitHubRepo = GitHubClient.toGitHubRepo(response.repository);

View File

@ -2,10 +2,11 @@ import { Notebook, stringifyNotebook, makeNotebookRecord, toJS } from "@nteract/
import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core"; import { FileType, IContent, IContentProvider, IEmptyContent, IGetParams, ServerConfig } from "@nteract/core";
import { from, Observable, of } from "rxjs"; import { from, Observable, of } from "rxjs";
import { AjaxResponse } from "rxjs/ajax"; import { AjaxResponse } from "rxjs/ajax";
import * as Base64Utils from "../Utils/Base64Utils";
import { HttpStatusCodes } from "../Common/Constants"; import { HttpStatusCodes } from "../Common/Constants";
import * as Logger from "../Common/Logger"; import * as Logger from "../Common/Logger";
import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil"; import { NotebookUtil } from "../Explorer/Notebook/NotebookUtil";
import { GitHubClient, IGitHubFile, IGitHubResponse, IGitHubCommit, IGitHubBranch } from "./GitHubClient"; import { GitHubClient, IGitHubFile, IGitHubResponse } from "./GitHubClient";
import * as GitHubUtils from "../Utils/GitHubUtils"; import * as GitHubUtils from "../Utils/GitHubUtils";
import UrlUtility from "../Common/UrlUtility"; import UrlUtility from "../Common/UrlUtility";
@ -131,7 +132,7 @@ export class GitHubContentProvider implements IContentProvider {
throw new GitHubContentProviderError(`Failed to parse ${uri}`); throw new GitHubContentProviderError(`Failed to parse ${uri}`);
} }
const content = btoa(stringifyNotebook(toJS(makeNotebookRecord()))); const content = Base64Utils.utf8ToB64(stringifyNotebook(toJS(makeNotebookRecord())));
const options: Intl.DateTimeFormatOptions = { const options: Intl.DateTimeFormatOptions = {
year: "numeric", year: "numeric",
month: "short", month: "short",
@ -195,34 +196,63 @@ export class GitHubContentProvider implements IContentProvider {
return from( return from(
this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => { this.getContent(uri).then(async (content: IGitHubResponse<IGitHubFile | IGitHubFile[]>) => {
try { try {
const commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save"); let commitMsg: string;
if (content.status === HttpStatusCodes.NotFound) {
// We'll create a new file since it doesn't exist
commitMsg = await this.params.promptForCommitMsg("Save", "Save");
if (!commitMsg) {
throw new GitHubContentProviderError("Couldn't get a commit message");
}
} else {
commitMsg = await this.validateContentAndGetCommitMsg(content, "Save", "Save");
}
let updatedContent: string; let updatedContent: string;
if (model.type === "notebook") { if (model.type === "notebook") {
updatedContent = btoa(stringifyNotebook(model.content as Notebook)); updatedContent = Base64Utils.utf8ToB64(stringifyNotebook(model.content as Notebook));
} else if (model.type === "file") { } else if (model.type === "file") {
updatedContent = model.content as string; updatedContent = model.content as string;
if (model.format !== "base64") { if (model.format !== "base64") {
updatedContent = btoa(updatedContent); updatedContent = Base64Utils.utf8ToB64(updatedContent);
} }
} else { } else {
throw new GitHubContentProviderError("Unsupported content type"); throw new GitHubContentProviderError("Unsupported content type");
} }
const gitHubFile = content.data as IGitHubFile; const contentInfo = GitHubUtils.fromContentUri(uri);
const response = await this.params.gitHubClient.createOrUpdateFileAsync( let gitHubFile: IGitHubFile;
gitHubFile.repo.owner, if (content.data) {
gitHubFile.repo.name, gitHubFile = content.data as IGitHubFile;
gitHubFile.branch.name,
gitHubFile.path,
commitMsg,
updatedContent,
gitHubFile.sha
);
if (response.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to update", response.status);
} }
const response = await this.params.gitHubClient.createOrUpdateFileAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
contentInfo.path,
commitMsg,
updatedContent,
gitHubFile?.sha
);
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.Created) {
throw new GitHubContentProviderError("Failed to create or update", response.status);
}
if (gitHubFile) {
gitHubFile.commit = response.data; gitHubFile.commit = response.data;
} else {
const contentResponse = await this.params.gitHubClient.getContentsAsync(
contentInfo.owner,
contentInfo.repo,
contentInfo.branch,
contentInfo.path
);
if (contentResponse.status !== HttpStatusCodes.OK) {
throw new GitHubContentProviderError("Failed to get content", response.status);
}
gitHubFile = contentResponse.data as IGitHubFile;
}
return this.createSuccessAjaxResponse( return this.createSuccessAjaxResponse(
HttpStatusCodes.OK, HttpStatusCodes.OK,

View File

@ -47,7 +47,8 @@ const sampleGalleryItems: IGalleryItem[] = [
isSample: false, isSample: false,
downloads: 0, downloads: 0,
favorites: 0, favorites: 0,
views: 0 views: 0,
newCellId: undefined
} }
]; ];
@ -185,7 +186,7 @@ describe("Gallery", () => {
json: () => undefined as any json: () => undefined as any
}); });
const response = await junoClient.getNotebook(id); const response = await junoClient.getNotebookInfo(id);
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);
expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`); expect(window.fetch).toBeCalledWith(`${configContext.JUNO_ENDPOINT}/api/notebooks/gallery/${id}`);
@ -353,7 +354,7 @@ describe("Gallery", () => {
json: () => undefined as any json: () => undefined as any
}); });
const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content); const response = await junoClient.publishNotebook(name, description, tags, author, thumbnailUrl, content, false);
const authorizationHeader = getAuthorizationHeader(); const authorizationHeader = getAuthorizationHeader();
expect(response.status).toBe(HttpStatusCodes.OK); expect(response.status).toBe(HttpStatusCodes.OK);

View File

@ -36,6 +36,16 @@ export interface IGalleryItem {
downloads: number; downloads: number;
favorites: number; favorites: number;
views: number; views: number;
newCellId: string;
}
export interface IPublicGalleryData {
metadata: IPublicGalleryMetaData;
notebooksData: IGalleryItem[];
}
export interface IPublicGalleryMetaData {
acceptedCodeOfConduct: boolean;
} }
export interface IUserGallery { export interface IUserGallery {
@ -162,7 +172,62 @@ export class JunoClient {
return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`); return this.getNotebooks(`${this.getNotebooksUrl()}/gallery/public`);
} }
public async getNotebook(id: string): Promise<IJunoResponse<IGalleryItem>> { // will be renamed once feature.enableCodeOfConduct flag is removed
public async fetchPublicNotebooks(): Promise<IJunoResponse<IPublicGalleryData>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/public`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: IPublicGalleryData;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async acceptCodeOfConduct(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/acceptCodeOfConduct`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async isCodeOfConductAccepted(): Promise<IJunoResponse<boolean>> {
const url = `${this.getNotebooksAccountUrl()}/gallery/isCodeOfConductAccepted`;
const response = await window.fetch(url, {
method: "PATCH",
headers: JunoClient.getHeaders()
});
let data: boolean;
if (response.status === HttpStatusCodes.OK) {
data = await response.json();
}
return {
status: response.status,
data
};
}
public async getNotebookInfo(id: string): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(this.getNotebookInfoUrl(id)); const response = await window.fetch(this.getNotebookInfoUrl(id));
let data: IGalleryItem; let data: IGalleryItem;
@ -292,12 +357,23 @@ export class JunoClient {
tags: string[], tags: string[],
author: string, author: string,
thumbnailUrl: string, thumbnailUrl: string,
content: string content: string,
isLinkInjectionEnabled: boolean
): Promise<IJunoResponse<IGalleryItem>> { ): Promise<IJunoResponse<IGalleryItem>> {
const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, { const response = await window.fetch(`${this.getNotebooksAccountUrl()}/gallery`, {
method: "PUT", method: "PUT",
headers: JunoClient.getHeaders(), headers: JunoClient.getHeaders(),
body: JSON.stringify({ body: isLinkInjectionEnabled
? JSON.stringify({
name,
description,
tags,
author,
thumbnailUrl,
content: JSON.parse(content),
addLinkToNotebookViewer: isLinkInjectionEnabled
} as IPublishNotebookRequest)
: JSON.stringify({
name, name,
description, description,
tags, tags,
@ -310,6 +386,8 @@ export class JunoClient {
let data: IGalleryItem; let data: IGalleryItem;
if (response.status === HttpStatusCodes.OK) { if (response.status === HttpStatusCodes.OK) {
data = await response.json(); data = await response.json();
} else {
throw new Error(`HTTP status ${response.status} thrown. ${(await response.json()).Message}`);
} }
return { return {

View File

@ -2,7 +2,7 @@ import "bootstrap/dist/css/bootstrap.css";
import { initializeIcons } from "office-ui-fabric-react/lib/Icons"; import { initializeIcons } from "office-ui-fabric-react/lib/Icons";
import React from "react"; import React from "react";
import * as ReactDOM from "react-dom"; import * as ReactDOM from "react-dom";
import { initializeConfiguration } from "../ConfigContext"; import { initializeConfiguration, configContext } from "../ConfigContext";
import { import {
NotebookViewerComponent, NotebookViewerComponent,
NotebookViewerComponentProps NotebookViewerComponentProps
@ -17,28 +17,41 @@ const onInit = async () => {
await initializeConfiguration(); await initializeConfiguration();
const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search); const galleryViewerProps = GalleryUtils.getGalleryViewerProps(window.location.search);
const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search); const notebookViewerProps = GalleryUtils.getNotebookViewerProps(window.location.search);
const backNavigationText = galleryViewerProps.selectedTab && GalleryUtils.getTabTitle(galleryViewerProps.selectedTab); let backNavigationText: string;
let onBackClick: () => void;
if (galleryViewerProps.selectedTab !== undefined) {
backNavigationText = GalleryUtils.getTabTitle(galleryViewerProps.selectedTab);
onBackClick = () => (window.location.href = `${configContext.hostedExplorerURL}gallery.html`);
}
const hideInputs = notebookViewerProps.hideInputs; const hideInputs = notebookViewerProps.hideInputs;
const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl); const notebookUrl = decodeURIComponent(notebookViewerProps.notebookUrl);
render(notebookUrl, backNavigationText, hideInputs);
const galleryItemId = notebookViewerProps.galleryItemId; const galleryItemId = notebookViewerProps.galleryItemId;
let galleryItem: IGalleryItem;
if (galleryItemId) { if (galleryItemId) {
const junoClient = new JunoClient(); const junoClient = new JunoClient();
const notebook = await junoClient.getNotebook(galleryItemId); const galleryItemJunoResponse = await junoClient.getNotebookInfo(galleryItemId);
render(notebookUrl, backNavigationText, hideInputs, notebook.data); galleryItem = galleryItemJunoResponse.data;
} }
render(notebookUrl, backNavigationText, hideInputs, galleryItem, onBackClick);
}; };
const render = (notebookUrl: string, backNavigationText: string, hideInputs: boolean, galleryItem?: IGalleryItem) => { const render = (
notebookUrl: string,
backNavigationText: string,
hideInputs: boolean,
galleryItem?: IGalleryItem,
onBackClick?: () => void
) => {
const props: NotebookViewerComponentProps = { const props: NotebookViewerComponentProps = {
junoClient: galleryItem ? new JunoClient() : undefined, junoClient: galleryItem ? new JunoClient() : undefined,
notebookUrl, notebookUrl,
galleryItem, galleryItem,
backNavigationText, backNavigationText,
hideInputs, hideInputs,
onBackClick: undefined, onBackClick: onBackClick,
onTagClick: undefined onTagClick: undefined
}; };

View File

@ -20,9 +20,13 @@ export class ConnectionStringParser {
const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute); const matches: string[] = connectionStringPart.match(Constants.EndpointsRegex.mongoCompute);
accessInput.accountName = matches && matches.length > 1 && matches[2]; accessInput.accountName = matches && matches.length > 1 && matches[2];
accessInput.apiKind = DataModels.ApiKind.MongoDBCompute; accessInput.apiKind = DataModels.ApiKind.MongoDBCompute;
} else if (RegExp(Constants.EndpointsRegex.cassandra).test(connectionStringPart)) { } else if (Constants.EndpointsRegex.cassandra.some(regex => RegExp(regex).test(connectionStringPart))) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.cassandra)[1]; Constants.EndpointsRegex.cassandra.forEach(regex => {
if (RegExp(regex).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(regex)[1];
accessInput.apiKind = DataModels.ApiKind.Cassandra; accessInput.apiKind = DataModels.ApiKind.Cassandra;
}
});
} else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) { } else if (RegExp(Constants.EndpointsRegex.table).test(connectionStringPart)) {
accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1]; accessInput.accountName = connectionStringPart.match(Constants.EndpointsRegex.table)[1];
accessInput.apiKind = DataModels.ApiKind.Table; accessInput.apiKind = DataModels.ApiKind.Table;

View File

@ -1,6 +1,7 @@
import * as _ from "underscore"; import * as _ from "underscore";
import * as Constants from "../Common/Constants"; import * as Constants from "../Common/Constants";
import * as DataModels from "../Contracts/DataModels"; import * as DataModels from "../Contracts/DataModels";
import { DefaultAccountExperienceType } from "../DefaultAccountExperienceType";
export class DefaultExperienceUtility { export class DefaultExperienceUtility {
public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string { public static getDefaultExperienceFromDatabaseAccount(databaseAccount: DataModels.DatabaseAccount): string {
@ -59,6 +60,25 @@ export class DefaultExperienceUtility {
} }
} }
public static mapDefaultExperienceStringToEnum(defaultExperience: string): DefaultAccountExperienceType {
switch (defaultExperience) {
case Constants.DefaultAccountExperience.DocumentDB:
return DefaultAccountExperienceType.DocumentDB;
case Constants.DefaultAccountExperience.Graph:
return DefaultAccountExperienceType.Graph;
case Constants.DefaultAccountExperience.MongoDB:
return DefaultAccountExperienceType.MongoDB;
case Constants.DefaultAccountExperience.Table:
return DefaultAccountExperienceType.Table;
case Constants.DefaultAccountExperience.Cassandra:
return DefaultAccountExperienceType.Cassandra;
case Constants.DefaultAccountExperience.ApiForMongoDB:
return DefaultAccountExperienceType.ApiForMongoDB;
default:
throw new Error(`Unsupported default experience type: ${defaultExperience}`);
}
}
private static _getDefaultExperience(kind: string, capabilities: DataModels.Capability[]): string { private static _getDefaultExperience(kind: string, capabilities: DataModels.Capability[]): string {
const defaultDefaultExperience: string = Constants.DefaultAccountExperience.DocumentDB; const defaultDefaultExperience: string = Constants.DefaultAccountExperience.DocumentDB;
const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind); const defaultExperienceFromKind: string = DefaultExperienceUtility._getDefaultExperienceFromAccountKind(kind);

View File

@ -3,6 +3,7 @@ import { sendMessage } from "../../Common/MessageHandler";
import { MessageTypes } from "../../Contracts/ExplorerContracts"; import { MessageTypes } from "../../Contracts/ExplorerContracts";
import { appInsights } from "../appInsights"; import { appInsights } from "../appInsights";
import { configContext } from "../../ConfigContext"; import { configContext } from "../../ConfigContext";
import { userContext } from "../../UserContext";
/** /**
* Class that persists telemetry data to the portal tables. * Class that persists telemetry data to the portal tables.
@ -115,6 +116,9 @@ export default class TelemetryProcessor {
private static getData(data?: any): any { private static getData(data?: any): any {
return { return {
// TODO: Need to `any` here since the window imports Explorer which can't be in strict mode yet
authType: (window as any).authType,
subscriptionId: userContext.subscriptionId,
platform: configContext.platform, platform: configContext.platform,
...(data ? data : []) ...(data ? data : [])
}; };

View File

@ -1,4 +1,5 @@
import { DatabaseAccount } from "./Contracts/DataModels"; import { DatabaseAccount } from "./Contracts/DataModels";
import { DefaultAccountExperienceType } from "./DefaultAccountExperienceType";
interface UserContext { interface UserContext {
masterKey?: string; masterKey?: string;
@ -9,6 +10,7 @@ interface UserContext {
accessToken?: string; accessToken?: string;
authorizationToken?: string; authorizationToken?: string;
resourceToken?: string; resourceToken?: string;
defaultExperience?: DefaultAccountExperienceType;
} }
const userContext: Readonly<UserContext> = {} as const; const userContext: Readonly<UserContext> = {} as const;

View File

@ -0,0 +1,11 @@
import * as Base64Utils from "./Base64Utils";
describe("Base64Utils", () => {
describe("utf8ToB64", () => {
it("should convert utf8 to base64", () => {
expect(Base64Utils.utf8ToB64("abcd")).toEqual(btoa("abcd"));
expect(Base64Utils.utf8ToB64("小飼弾")).toEqual("5bCP6aO85by+");
expect(Base64Utils.utf8ToB64("à mon hôpital préféré")).toEqual("w6AgbW9uIGjDtHBpdGFsIHByw6lmw6lyw6k=");
});
});
});

7
src/Utils/Base64Utils.ts Normal file
View File

@ -0,0 +1,7 @@
export const utf8ToB64 = (utf8Str: string): string => {
return btoa(
encodeURIComponent(utf8Str).replace(/%([0-9A-F]{2})/g, (_, args) => {
return String.fromCharCode(parseInt(args, 16));
})
);
};

View File

@ -16,7 +16,8 @@ const galleryItem: IGalleryItem = {
isSample: false, isSample: false,
downloads: 0, downloads: 0,
favorites: 0, favorites: 0,
views: 0 views: 0,
newCellId: undefined
}; };
describe("GalleryUtils", () => { describe("GalleryUtils", () => {

View File

@ -162,6 +162,7 @@ export type DatabaseAccountGetResults = ARMResourceProperties & {
}; };
/* The system generated resource properties associated with SQL databases, SQL containers, Gremlin databases and Gremlin graphs. */ /* The system generated resource properties associated with SQL databases, SQL containers, Gremlin databases and Gremlin graphs. */
// TODO: ExtendedResourceProperties was missing some properties such as _self which was manually added. Need to fix this in the RP spec.
export interface ExtendedResourceProperties { export interface ExtendedResourceProperties {
/* A system generated property. A unique identifier. */ /* A system generated property. A unique identifier. */
readonly _rid: string; readonly _rid: string;
@ -169,6 +170,8 @@ export interface ExtendedResourceProperties {
readonly _ts: unknown; readonly _ts: unknown;
/* A system generated property representing the resource etag required for optimistic concurrency control. */ /* A system generated property representing the resource etag required for optimistic concurrency control. */
readonly _etag: string; readonly _etag: string;
// TODO: This property was manually added. It should be auto-generated like the other properties.
readonly _self: string;
} }
/* An Azure Cosmos DB resource throughput. */ /* An Azure Cosmos DB resource throughput. */

View File

@ -296,6 +296,10 @@
<div data-bind="react: publishNotebookPaneAdapter"></div> <div data-bind="react: publishNotebookPaneAdapter"></div>
<!-- /ko --> <!-- /ko -->
<!-- ko if: isCopyNotebookPaneEnabled -->
<div data-bind="react: copyNotebookPaneAdapter"></div>
<!-- /ko -->
<!-- Global access token expiration dialog - Start --> <!-- Global access token expiration dialog - Start -->
<div <div
id="dataAccessTokenModal" id="dataAccessTokenModal"

View File

@ -30,6 +30,7 @@
"./src/Contracts/Versions.ts", "./src/Contracts/Versions.ts",
"./src/Controls/Heatmap/Heatmap.ts", "./src/Controls/Heatmap/Heatmap.ts",
"./src/Controls/Heatmap/HeatmapDatatypes.ts", "./src/Controls/Heatmap/HeatmapDatatypes.ts",
"./src/DefaultAccountExperienceType.ts",
"./src/Definitions/globals.d.ts", "./src/Definitions/globals.d.ts",
"./src/Definitions/html.d.ts", "./src/Definitions/html.d.ts",
"./src/Definitions/jquery-ui.d.ts", "./src/Definitions/jquery-ui.d.ts",
@ -66,6 +67,7 @@
"./src/Shared/Telemetry/TelemetryProcessor.ts", "./src/Shared/Telemetry/TelemetryProcessor.ts",
"./src/Shared/appInsights.ts", "./src/Shared/appInsights.ts",
"./src/UserContext.ts", "./src/UserContext.ts",
"./src/Utils/Base64Utils.ts",
"./src/Utils/GitHubUtils.ts", "./src/Utils/GitHubUtils.ts",
"./src/Utils/MessageValidation.ts", "./src/Utils/MessageValidation.ts",
"./src/Utils/OfferUtils.ts", "./src/Utils/OfferUtils.ts",

View File

@ -10,6 +10,7 @@ const CreateFileWebpack = require("create-file-webpack");
const childProcess = require("child_process"); const childProcess = require("child_process");
const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
const TerserPlugin = require("terser-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin");
const isCI = require("is-ci");
const gitSha = childProcess.execSync("git rev-parse HEAD").toString("utf8"); const gitSha = childProcess.execSync("git rev-parse HEAD").toString("utf8");
@ -214,8 +215,13 @@ module.exports = function(env = {}, argv = {}) {
}) })
] ]
}, },
watch: isCI || mode === "production" ? false : true,
// Hack since it is hard to disable watch entirely with webpack dev server https://github.com/webpack/webpack-dev-server/issues/1251#issuecomment-654240734
watchOptions: isCI ? { poll: 24 * 60 * 60 * 1000 } : {},
devServer: { devServer: {
hot: false, hot: !isCI,
inline: !isCI,
liveReload: !isCI,
https: true, https: true,
host: "0.0.0.0", host: "0.0.0.0",
port: envVars.PORT, port: envVars.PORT,