mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2025-12-21 01:41:31 +00:00
Initial Move from Azure DevOps to GitHub
This commit is contained in:
602
src/Explorer/Panes/AddCollectionPane.html
Normal file
602
src/Explorer/Panes/AddCollectionPane.html
Normal file
@@ -0,0 +1,602 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="setTemplateReady: true, click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" data-bind="attr: { id: id }">
|
||||
<!-- Add collection form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form data-bind="submit: submit" style="height: 100%">
|
||||
<div
|
||||
class="paneContentContainer"
|
||||
role="dialog"
|
||||
aria-labelledby="containerTitle"
|
||||
data-bind="template: { name: 'add-collection-inputs' }"
|
||||
></div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Add collection form -- End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="add-collection-inputs">
|
||||
<!-- Add collection header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span id="containerTitle" data-bind="text: title"></span>
|
||||
<div class="closeImg" id="closeBtnAddCollection" role="button" aria-label="Close pane"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }" tabindex="0">
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add collection header - End -->
|
||||
|
||||
<!-- Add collection errors - Start -->
|
||||
<div class="warningErrorContainer" aria-live="assertive" data-bind="visible: formErrors() && formErrors() !== ''">
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a class="errorLink" role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '' , click: showErrorDetails, event: { keypress: onMoreDetailsKeyPress }"
|
||||
tabindex="0">
|
||||
More details</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="warningErrorContainer" aria-live="assertive" data-bind="visible: formWarnings() && formWarnings() !== ''">
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/warning.svg" alt="Warning"></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formWarnings, attr: { title: formWarnings }"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add collection errors - End -->
|
||||
|
||||
<!-- upsell message - start -->
|
||||
<div class="infoBoxContainer" aria-live="assertive" data-bind="visible: formErrors && !formErrors()">
|
||||
<div class="infoBoxContent">
|
||||
<span><img class="infoBoxIcon" src="/info_color.svg" alt="Promo"></span>
|
||||
<span class="infoBoxDetails">
|
||||
<span class="infoBoxMessage" data-bind="text: upsellMessage, attr: { title: upsellMessage }"></span>
|
||||
<a class="underlinedLink" id="linkAddCollection" data-bind="attr: { 'aria-label': upsellMessageAriaLabel }"
|
||||
target="_blank" href="https://aka.ms/azure-cosmos-db-pricing" tabindex="0">
|
||||
More details</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- upsell message - end -->
|
||||
|
||||
<!-- Add collection inputs - Start -->
|
||||
<div class="paneMainContent" data-bind="visible: !maxCollectionsReached()">
|
||||
<div data-bind="visible: !isPreferredApiTable()">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">Database id</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth">A database is analogous to a namespace. It is the unit of
|
||||
management for a set of containers.</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="createNewDatabaseOrUseExisting">
|
||||
<input class="createNewDatabaseOrUseExistingRadio" aria-label="Create new database" name="databaseType"
|
||||
type="radio" role="radio" id="databaseCreateNew" data-test="addCollection-createNewDatabase"
|
||||
tabindex="0"
|
||||
data-bind="checked: databaseCreateNew, checkedValue: true, attr: { 'aria-checked': databaseCreateNew() ? 'true' : 'false' }" />
|
||||
<span class="createNewDatabaseOrUseExistingSpace" for="databaseCreateNew">Create new</span>
|
||||
|
||||
<input class="createNewDatabaseOrUseExistingRadio" aria-label="Use existing database" name="databaseType"
|
||||
type="radio" role="radio" id="databaseUseExisting" data-test="addCollection-existingDatabase"
|
||||
tabindex="0"
|
||||
data-bind="checked: databaseCreateNew, checkedValue: false, attr: { 'aria-checked': !databaseCreateNew() ? 'true' : 'false' }" />
|
||||
<span class="createNewDatabaseOrUseExistingSpace" for="databaseUseExisting">Use existing</span>
|
||||
</div>
|
||||
|
||||
<input name="newDatabaseId" id="databaseId" data-test="addCollection-newDatabaseId" aria-required="true"
|
||||
type="text" autocomplete="off" pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'" placeholder="Type a new database id"
|
||||
size="40" class="collid"
|
||||
data-bind="visible: databaseCreateNew, textInput: databaseId, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Database id" autofocus>
|
||||
|
||||
<input name="existingDatabaseId" id="existingDatabaseId" data-test="addCollection-existingDatabaseId"
|
||||
aria-required="true" type="text" autocomplete="off" pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'" list="databasesList"
|
||||
placeholder="Choose an existing database" size="40" class="collid"
|
||||
data-bind="visible: !databaseCreateNew(), textInput: databaseId, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Database id">
|
||||
|
||||
<datalist id="databasesList" data-bind="foreach: databaseIds" data-bind="visible: databaseCreateNew">
|
||||
<option data-bind="value: $data">
|
||||
</datalist>
|
||||
<!-- Database provisioned throughput - Start -->
|
||||
<div class="databaseProvision" aria-label="New database provision support"
|
||||
data-bind="visible: databaseCreateNew">
|
||||
<input tabindex="0" type="checkbox" data-test="addCollectionPane-databaseSharedThroughput"
|
||||
id="addCollection-databaseSharedThroughput" title="Provision shared throughput"
|
||||
data-bind="checked: databaseCreateNewShared" />
|
||||
<span class="databaseProvisionText" for="databaseSharedThroughput">Provision database throughput</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext provisionDatabaseThroughput">Provisioned throughput at the database level will
|
||||
be shared across all containers within the database.</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: databaseCreateNewShared() && databaseCreateNew()">
|
||||
<!-- 1 -->
|
||||
<throughput-input-autopilot-v3 params="{
|
||||
testId: 'databaseThroughputValue',
|
||||
value: throughputDatabase,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: databaseCreateNewShared,
|
||||
label: sharedThroughputRangeText,
|
||||
ariaLabel: sharedThroughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAck',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newContainer-databaseThroughput-autoPilotRadio',
|
||||
throughputProvisionedRadioId: 'newContainer-databaseThroughput-manualRadio',
|
||||
throughputModeRadioName: 'sharedThroughputModeRadio',
|
||||
isAutoPilotSelected: isSharedAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: sharedAutoPilotThroughput,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
showAutoPilot: !isFreeTierAccount()
|
||||
}">
|
||||
</throughput-input-autopilot-v3>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: databaseCreateNewShared() && databaseCreateNew()">
|
||||
<!-- 1 -->
|
||||
<throughput-input params="{
|
||||
testId: 'databaseThroughputValue',
|
||||
value: throughputDatabase,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: databaseCreateNewShared,
|
||||
label: sharedThroughputRangeText,
|
||||
ariaLabel: sharedThroughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAck',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newContainer-databaseThroughput-autoPilotRadio',
|
||||
throughputProvisionedRadioId: 'newContainer-databaseThroughput-manualRadio',
|
||||
throughputModeRadioName: 'sharedThroughputModeRadio',
|
||||
isAutoPilotSelected: isSharedAutoPilotSelected,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
autoPilotTiersList: sharedAutoPilotTiersList,
|
||||
selectedAutoPilotTier: selectedSharedAutoPilotTier
|
||||
}">
|
||||
</throughput-input>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- Database provisioned throughput - End -->
|
||||
</div>
|
||||
|
||||
<div class="seconddivpadding">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel" data-bind="text: collectionIdTitle"></span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth">Unique identifier for the container and used for
|
||||
id-based
|
||||
routing through REST and all SDKs</span>
|
||||
</span>
|
||||
</p>
|
||||
<input name="collectionId" id="containerId" data-test="addCollection-collectionId" type="text"
|
||||
aria-required="true" autocomplete="off" pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'" placeholder="e.g., Container1"
|
||||
size="40" class="textfontclr collid"
|
||||
data-bind="value: collectionId, attr: { 'aria-label': collectionIdTitle }">
|
||||
</div>
|
||||
|
||||
<!-- <p class="seconddivpadding" data-bind="visible:(container.isPreferredApiDocumentDB() || container.isPreferredApiGraph()) && !databaseHasSharedOffer()">
|
||||
Where did 'fixed' containers go?
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext noFixedCollectionsTooltipWidth">
|
||||
We lowered the minimum throughput for partitioned containers to 400 RU/s, removing the only drawback partitioned containers had. <br/><br/>
|
||||
We are planning to deprecate ability to create non-partitioned containers, as they do not allow you to scale elastically.
|
||||
If for some reason you still need a container without partition key, you can use our SDKs to create one. <br/><br/>
|
||||
Please <a href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20Hosted%20Data%20Explorer%20Feedback">contact us</a> if you have questions or concerns.
|
||||
</span>
|
||||
</span>
|
||||
</p> -->
|
||||
|
||||
<!-- Indexing For Shared Throughput - start -->
|
||||
<div class="seconddivpadding"
|
||||
data-bind="visible: showIndexingOptionsForSharedThroughput() && !container.isPreferredApiMongoDB()">
|
||||
<div class="useIndexingForSharedThroughput createNewDatabaseOrUseExisting"
|
||||
aria-label="Indexing For Shared Throughput">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">Indexing</span>
|
||||
</p>
|
||||
<div>
|
||||
<input type="radio" id="useIndexingForSharedThroughputOn" name="useIndexingForSharedThroughput"
|
||||
value="on" class="createNewDatabaseOrUseExistingRadio"
|
||||
data-bind="checked: useIndexingForSharedThroughput, checkedValue: true">
|
||||
<span class="createNewDatabaseOrUseExistingSpace"
|
||||
for="useIndexingForSharedThroughputOn">Automatic</span>
|
||||
<input type="radio" id="useIndexingForSharedThroughputOff" name="useIndexingForSharedThroughput"
|
||||
value="off" class="createNewDatabaseOrUseExistingRadio"
|
||||
data-bind="checked: useIndexingForSharedThroughput, checkedValue: false">
|
||||
<span class="createNewDatabaseOrUseExistingSpace" for="useIndexingForSharedThroughputOff">Off</span>
|
||||
</div>
|
||||
<p data-bind="visible: useIndexingForSharedThroughput">
|
||||
All properties in your documents will be indexed by default for flexible and efficient queries.
|
||||
<a class="errorLink" href="https://aka.ms/cosmos-indexing-policy" target="_blank">Learn
|
||||
more</a>
|
||||
</p>
|
||||
<p data-bind="visible: useIndexingForSharedThroughput() === false">Indexing will be turned off.
|
||||
Recommended
|
||||
if you don't need to run queries or only have key value operations. <a class="errorLink"
|
||||
href="https://aka.ms/cosmos-indexing-policy" target="_blank">Learn
|
||||
more</a></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Indexing For Shared Throughput - end -->
|
||||
|
||||
<p class="seconddivpadding"
|
||||
data-bind="visible: container.isPreferredApiMongoDB() && !databaseHasSharedOffer() || container.isFixedCollectionWithSharedThroughputSupported">
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">Storage capacity</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth">This is the maximum storage size of the container. Storage is
|
||||
billed per GB based on consumption.</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="tabs">
|
||||
<div tabindex="0"
|
||||
data-bind="event: { keydown: onStorageOptionsKeyDown }, visible: container.isPreferredApiMongoDB() && !databaseHasSharedOffer() || container.isFixedCollectionWithSharedThroughputSupported"
|
||||
aria-label="Storage capacity">
|
||||
<!-- Fixed option button - Start -->
|
||||
<div class="tab">
|
||||
<input type="radio" id="tab1" name="storage" value="10" class="radio" data-bind="checked: storage">
|
||||
<label for="tab1">Fixed (10 GB)</label>
|
||||
</div>
|
||||
<!-- Fixed option button - End -->
|
||||
|
||||
<!-- Unlimited option button - Start -->
|
||||
<div class="tab">
|
||||
<input type="radio" id="tab2" name="storage" value="100" class="radio" data-bind="checked: storage">
|
||||
<label for="tab2">Unlimited</label>
|
||||
</div>
|
||||
<!-- Unlimited option button - End -->
|
||||
</div>
|
||||
|
||||
<!-- Fixed Button Content - Start -->
|
||||
<div class="tabcontent" data-bind="visible: isFixedStorageSelected() && !databaseHasSharedOffer()">
|
||||
<!-- 2 -->
|
||||
<throughput-input params="{
|
||||
testId: 'fixedThroughputValue',
|
||||
value: throughputSinglePartition,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: isFixedStorageSelected() && !databaseHasSharedOffer(),
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost
|
||||
showAsMandatory: true,
|
||||
isFixed: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
canExceedMaximumValue: canExceedMaximumValue
|
||||
}">
|
||||
</throughput-input>
|
||||
<div data-bind="visible: rupmVisible">
|
||||
<div class="tabs">
|
||||
<p class="pkPadding">
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">RU/m</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext throughputRuInfo">
|
||||
For each 100 Request Units per second (RU/s) provisioned, 1,000 Request Units
|
||||
per
|
||||
minute
|
||||
(RU/m) can be provisioned. E.g.: for a container with 5,000 RU/s provisioned
|
||||
with
|
||||
RU/m
|
||||
enabled, the RU/m budget will be 50,000 RU/m.
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<div tabindex="0" data-bind="event: { keydown: onRupmOptionsKeyDown }" aria-label="RU/m">
|
||||
<div class="tab">
|
||||
<input type="radio" id="rupmOn" name="rupmcoll" value="on" class="radio"
|
||||
data-bind="checked: rupm">
|
||||
<label for="rupmOn">ON</label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input type="radio" id="rupmOff" name="rupmcoll" value="off" class="radio"
|
||||
data-bind="checked: rupm">
|
||||
<label for="rupmOff">OFF</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fixed Button Content - End -->
|
||||
|
||||
<!-- Unlimited Button Content - Start -->
|
||||
<div class="tabcontent" data-bind="visible: isUnlimitedStorageSelected() || databaseHasSharedOffer()">
|
||||
<div data-bind="visible: rupmVisible">
|
||||
<div class="tabs">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">RU/m</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext throughputRuInfo">
|
||||
For each 100 Request Units per second (RU/s) provisioned, 1,000 Request Units
|
||||
per
|
||||
minute
|
||||
(RU/m) can be provisioned. E.g.: for a container with 5,000 RU/s provisioned
|
||||
with
|
||||
RU/m
|
||||
enabled, the RU/m budget will be 50,000 RU/m.
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
<div tabindex="0" data-bind="event: { keydown: onRupmOptionsKeyDown }" aria-label="RU/m">
|
||||
<div class="tab">
|
||||
<input type="radio" id="rupmOn2" name="rupmcoll2" value="on" class="radio"
|
||||
data-bind="checked: rupm">
|
||||
<label for="rupmOn2">ON</label>
|
||||
</div>
|
||||
<div class="tab">
|
||||
<input type="radio" id="rupmOff2" name="rupmcoll2" value="off" class="radio"
|
||||
data-bind="checked: rupm">
|
||||
<label for="rupmOff2">OFF</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: partitionKeyVisible">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel" data-bind="text: partitionKeyName"></span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth">The <span data-bind="text: partitionKeyName"></span>
|
||||
is used to automatically partition
|
||||
data among multiple servers for scalability. Choose a JSON property name that has a
|
||||
wide
|
||||
range of values and is likely to have evenly distributed access patterns.</span>
|
||||
</span>
|
||||
</p>
|
||||
<input type="text" data-test="addCollection-partitionKeyValue" aria-required="true" size="40"
|
||||
class="textfontclr collid" data-bind="textInput: partitionKey,
|
||||
attr: {
|
||||
placeholder: partitionKeyPlaceholder,
|
||||
required: partitionKeyVisible(),
|
||||
'aria-label': partitionKeyName,
|
||||
pattern: partitionKeyPattern,
|
||||
title: partitionKeyTitle
|
||||
}">
|
||||
</div>
|
||||
<!-- large parition key - start -->
|
||||
<div class="largePartitionKey" aria-label="Large Partition Key" data-bind="visible: partitionKeyVisible">
|
||||
<input tabindex="0" type="checkbox" id="largePartitionKey" data-test="addCollection-largePartitionKey"
|
||||
title="Large Partition Key" data-bind="checked: largePartitionKey" />
|
||||
<span for="largePartitionKey">My <span data-bind="text: lowerCasePartitionKeyName"></span> is
|
||||
larger
|
||||
than 100 bytes</span>
|
||||
<p data-bind="visible: largePartitionKey" class="largePartitionKeyDescription"
|
||||
data-test="addCollection-largePartitionKeyDescription">Old SDKs do not work with containers
|
||||
that
|
||||
support large <span data-bind="text: lowerCasePartitionKeyName"></span>s, ensure you are
|
||||
using
|
||||
the
|
||||
right SDK version. <a class="errorLink" href="https://aka.ms/cosmosdb/pkv2" target="_blank">Learn
|
||||
more</a></p>
|
||||
</div>
|
||||
<!-- large parition key - end -->
|
||||
<!-- Provision collection throughput checkox - start -->
|
||||
<div class="pkPadding" data-bind="visible: databaseHasSharedOffer() && !databaseCreateNew()">
|
||||
<input type="checkbox" id="collectionSharedThroughput"
|
||||
data-bind="checked: collectionWithThroughputInShared, attr: {title:collectionWithThroughputInSharedTitle}" />
|
||||
<span for="collectionSharedThroughput" data-bind="text: collectionWithThroughputInSharedTitle"></span>
|
||||
<span class="leftAlignInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext sharedCollectionThroughputTooltipWidth">You can optionally
|
||||
provision
|
||||
dedicated throughput for a container within a database that has throughput provisioned.
|
||||
This
|
||||
dedicated throughput amount will not be shared with other containers in the database and
|
||||
does
|
||||
not count towards the throughput you provisioned for the database. This throughput
|
||||
amount
|
||||
will
|
||||
be billed in addition to the throughput amount you provisioned at the database
|
||||
level.</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Provision collection throughput checkox - end -->
|
||||
<!-- Provision collection throughput spinner - start -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: displayCollectionThroughput" data-test="addCollection-displayCollectionThroughput">
|
||||
<!-- 3 -->
|
||||
<throughput-input-autopilot-v3 params="{
|
||||
testId: 'collectionThroughputValue',
|
||||
value: throughputMultiPartition,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: !databaseHasSharedOffer() || collectionWithThroughputInShared(),
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: dedicatedRequestUnitsUsageCost,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAckCollection',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newContainer-containerThroughput-autoPilotRadio',
|
||||
throughputProvisionedRadioId: 'newContainer-containerThroughput-manualRadio',
|
||||
throughputModeRadioName: 'throughputModeRadioName',
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: autoPilotThroughput,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
showAutoPilot: !isFixedStorageSelected()
|
||||
}">
|
||||
</throughput-input-autopilot-v3>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: displayCollectionThroughput" data-test="addCollection-displayCollectionThroughput">
|
||||
<!-- 3 -->
|
||||
<throughput-input params="{
|
||||
testId: 'collectionThroughputValue',
|
||||
value: throughputMultiPartition,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: !databaseHasSharedOffer() || collectionWithThroughputInShared(),
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: dedicatedRequestUnitsUsageCost,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAckCollection',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newContainer-containerThroughput-autoPilotRadio',
|
||||
throughputProvisionedRadioId: 'newContainer-containerThroughput-manualRadio',
|
||||
throughputModeRadioName: 'throughputModeRadioName',
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
autoPilotTiersList: autoPilotTiersList,
|
||||
selectedAutoPilotTier: selectedAutoPilotTier,
|
||||
showAutoPilot: !isFixedStorageSelected()
|
||||
}">
|
||||
</throughput-input>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- Provision collection throughput spinner - end -->
|
||||
<!-- Enable analytical storage - start -->
|
||||
<div class="enableAnalyticalStorage pkPadding" aria-label="Enable Analytical Store"
|
||||
data-bind="visible: isSynapseLinkSupported">
|
||||
<div>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span class="addCollectionLabel">Analytical store</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth">
|
||||
Enable analytical store capability to perform near real-time analytics on your operational
|
||||
data, without impacting the performance of transactional workloads.
|
||||
Learn more <a class="errorLink" href="https://aka.ms/analytical-store-overview"
|
||||
target="_blank">here</a>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="paragraph">
|
||||
<input class="enableAnalyticalStorageRadio" id="enableAnalyticalStorageRadioOn"
|
||||
name="analyticalStore" type="radio" role="radio" tabindex="0" data-bind="
|
||||
disable: showEnableSynapseLink,
|
||||
checked: isAnalyticalStorageOn,
|
||||
checkedValue: true,
|
||||
attr: {
|
||||
'aria-checked': isAnalyticalStorageOn() ? 'true' : 'false'
|
||||
}" />
|
||||
<span for="enableAnalyticalStorageRadioOn" data-bind="disable: showEnableSynapseLink">
|
||||
On
|
||||
</span>
|
||||
|
||||
<input class="enableAnalyticalStorageRadio" id="enableAnalyticalStorageRadioOff"
|
||||
name="analyticalStore" type="radio" role="radio" tabindex="0" data-bind="
|
||||
disable: showEnableSynapseLink,
|
||||
checked: isAnalyticalStorageOn,
|
||||
checkedValue: false,
|
||||
attr: {
|
||||
'aria-checked': isAnalyticalStorageOn() ? 'false' : 'true'
|
||||
}" />
|
||||
<span for="enableAnalyticalStorageRadioOff" data-bind="disable: showEnableSynapseLink">
|
||||
Off
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="paragraph italic" data-bind="visible: ttl90DaysEnabled() && isAnalyticalStorageOn()">
|
||||
By default, Analytical Time-to-Live will be configured to retain 90 days of data in the analytical
|
||||
store. You can configure a custom retention policy in the 'Settings' tab.
|
||||
<span><a class="errorLink" href="https://aka.ms/cosmosdb-analytical-ttl" target="_blank">Learn
|
||||
more</a></span>
|
||||
</div>
|
||||
|
||||
<div class="paragraph" data-bind="visible: showEnableSynapseLink">
|
||||
Azure Synapse Link is required for creating an analytical
|
||||
store container. Enable Synapse Link for this
|
||||
Cosmos DB account.
|
||||
<span><a class="errorLink" href="https://aka.ms/cosmosdb-synapselink" target="_blank">Learn
|
||||
more</a></span>
|
||||
</div>
|
||||
|
||||
<div class="paragraph" data-bind="visible: showEnableSynapseLink">
|
||||
<button class="button" type="button" data-bind="
|
||||
click: onEnableSynapseLinkButtonClicked,
|
||||
disable: isSynapseLinkUpdating,
|
||||
css: {
|
||||
enabled: !isSynapseLinkUpdating(),
|
||||
disabled: isSynapseLinkUpdating
|
||||
}
|
||||
">Enable</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Enable analytical storage - end -->
|
||||
</div>
|
||||
<!-- Unlimited Button Content - End -->
|
||||
</div>
|
||||
<div class="uniqueIndexesContainer" data-bind="visible: uniqueKeysVisible">
|
||||
<p class="uniqueKeys">
|
||||
<span class="addCollectionLabel">Unique keys</span>
|
||||
<span class="uniqueInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="uniqueTooltiptext infoTooltipWidth">Unique keys provide developers with the
|
||||
ability to add a layer of data integrity to their database. By creating a unique key policy when a
|
||||
container is created, you ensure the uniqueness of one or more values per partition key.</span>
|
||||
</span>
|
||||
</p>
|
||||
<dynamic-list
|
||||
params="{ listItems: uniqueKeys, placeholder: uniqueKeysPlaceholder(), ariaLabel: 'Write a comma separated path list of unique keys', buttonText: 'Add unique key' }">
|
||||
</dynamic-list>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input name="createCollection" id="submitBtnAddCollection" data-test="addCollection-createCollection"
|
||||
type="submit" value="OK" class="btncreatecoll1">
|
||||
</div>
|
||||
</div>
|
||||
<div data-bind="visible: maxCollectionsReached">
|
||||
<error-display params="{ errorMsg: maxCollectionsReachedMessage }"></error-display>
|
||||
</div>
|
||||
<!-- Add collection inputs - End -->
|
||||
</script>
|
||||
74
src/Explorer/Panes/AddCollectionPane.test.ts
Normal file
74
src/Explorer/Panes/AddCollectionPane.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import AddCollectionPane from "./AddCollectionPane";
|
||||
import Explorer from "../Explorer";
|
||||
import ko from "knockout";
|
||||
import { AutopilotTier } from "../../Contracts/DataModels";
|
||||
|
||||
jest.mock("../Tabs/NotebookTab");
|
||||
|
||||
describe("Add Collection Pane", () => {
|
||||
describe("isValid()", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
const mockDatabaseAccount: ViewModels.DatabaseAccount = {
|
||||
id: "mock",
|
||||
kind: "DocumentDB",
|
||||
location: "",
|
||||
name: "mock",
|
||||
properties: undefined,
|
||||
type: undefined,
|
||||
tags: []
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
explorer.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
});
|
||||
|
||||
it("should be true if autopilot enabled and select valid tier", () => {
|
||||
explorer.databaseAccount(mockDatabaseAccount);
|
||||
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
|
||||
addCollectionPane.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
addCollectionPane.isAutoPilotSelected(true);
|
||||
addCollectionPane.selectedAutoPilotTier(AutopilotTier.Tier2);
|
||||
expect(addCollectionPane.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if autopilot enabled and select invalid tier", () => {
|
||||
explorer.databaseAccount(mockDatabaseAccount);
|
||||
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
|
||||
addCollectionPane.hasAutoPilotV2FeatureFlag = ko.computed<boolean>(() => true);
|
||||
addCollectionPane.isAutoPilotSelected(true);
|
||||
addCollectionPane.selectedAutoPilotTier(0);
|
||||
expect(addCollectionPane.isValid()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true if graph API and partition key is not /id nor /label", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.Graph.toLowerCase());
|
||||
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
|
||||
addCollectionPane.partitionKey("/blah");
|
||||
expect(addCollectionPane.isValid()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if graph API and partition key is /id or /label", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.Graph.toLowerCase());
|
||||
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
|
||||
addCollectionPane.partitionKey("/id");
|
||||
expect(addCollectionPane.isValid()).toBe(false);
|
||||
|
||||
addCollectionPane.partitionKey("/label");
|
||||
expect(addCollectionPane.isValid()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true for any non-graph API with /id or /label partition key", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.DocumentDB.toLowerCase());
|
||||
const addCollectionPane = explorer.addCollectionPane as AddCollectionPane;
|
||||
|
||||
addCollectionPane.partitionKey("/id");
|
||||
expect(addCollectionPane.isValid()).toBe(true);
|
||||
|
||||
addCollectionPane.partitionKey("/label");
|
||||
expect(addCollectionPane.isValid()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
1357
src/Explorer/Panes/AddCollectionPane.ts
Normal file
1357
src/Explorer/Panes/AddCollectionPane.ts
Normal file
File diff suppressed because it is too large
Load Diff
169
src/Explorer/Panes/AddDatabasePane.html
Normal file
169
src/Explorer/Panes/AddDatabasePane.html
Normal file
@@ -0,0 +1,169 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" data-bind="attr: { id: id }">
|
||||
<!-- Add database form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form data-bind="submit: submit" style="height: 100%">
|
||||
<div
|
||||
class="paneContentContainer"
|
||||
role="dialog"
|
||||
aria-labelledby="databaseTitle"
|
||||
data-bind="template: { name: 'add-database-inputs' }"
|
||||
></div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Add database form -- End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/html" id="add-database-inputs">
|
||||
<!-- Add database header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span id="databaseTitle" data-bind="text: title"></span>
|
||||
<div class="closeImg" role="button" aria-label="Close pane"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }" tabindex="0">
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add database header - End -->
|
||||
|
||||
<!-- Add database errors - Start -->
|
||||
<div class="warningErrorContainer" aria-live="assertive" data-bind="visible: formErrors() && formErrors() !== ''">
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a class="errorLink" role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails, event: { keypress: onMoreDetailsKeyPress }"
|
||||
tabindex="0">
|
||||
More details</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add database errors - End -->
|
||||
|
||||
<!-- upsell message - start -->
|
||||
<div class="infoBoxContainer" aria-live="assertive" data-bind="visible: formErrors && !formErrors()">
|
||||
<div class="infoBoxContent">
|
||||
<span><img class="infoBoxIcon" src="/info_color.svg" alt="Promo"></span>
|
||||
<span class="infoBoxDetails">
|
||||
<span class="infoBoxMessage" data-bind="text: upsellMessage, attr: { title: upsellMessage }"></span>
|
||||
<a class="underlinedLink" id="linkAddDatabase" data-bind="attr: { 'aria-label': upsellMessageAriaLabel }"
|
||||
target="_blank" href="https://aka.ms/azure-cosmos-db-pricing" tabindex="0">
|
||||
More details</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- upsell message - end -->
|
||||
|
||||
<!-- Add database inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span>
|
||||
<span data-bind="text: databaseIdLabel"></span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext infoTooltipWidth" data-bind="text: databaseIdTooltipText"></span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<input id="database-id" type="text" aria-required="true" autocomplete="off" pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'" placeholder="Type a new database id"
|
||||
size="40" class="collid" data-bind="textInput: databaseId, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Database id" autofocus>
|
||||
<div class="databaseProvision" aria-label="New database provision support">
|
||||
<input tabindex="0" type="checkbox" id="addDatabasePane-databaseSharedThroughput"
|
||||
title="Provision shared throughput" data-bind="checked: databaseCreateNewShared" />
|
||||
<span class="databaseProvisionText" for="databaseSharedThroughput">Provision throughput</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information">
|
||||
<span class="tooltiptext provisionDatabaseThroughput"
|
||||
data-bind="text: databaseLevelThroughputTooltipText"></span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: databaseCreateNewShared">
|
||||
<throughput-input-autopilot-v3 params="{
|
||||
step: 100,
|
||||
value: throughput,
|
||||
testId: 'sharedThroughputValue',
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: databaseCreateNewShared,
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAckDatabase',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newDatabase-databaseThroughput-autoPilotRadio',
|
||||
throughputProvisionedRadioId: 'newDatabase-databaseThroughput-manualRadio',
|
||||
throughputModeRadioName: 'throughputModeRadioName',
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: maxAutoPilotThroughputSet,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
showAutoPilot: !isFreeTierAccount()
|
||||
}">
|
||||
</throughput-input-autopilot-v3>
|
||||
<p data-bind="visible: canRequestSupport">
|
||||
<!-- TODO: Replace link with call to the Azure Support blade --><a
|
||||
href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20More%20Throughput%20Request">Contact
|
||||
support</a> for more than <span data-bind="text: maxThroughputRUText"></span> RU/s.</p>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: databaseCreateNewShared">
|
||||
<throughput-input params="{
|
||||
step: 100,
|
||||
value: throughput,
|
||||
testId: 'sharedThroughputValue',
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: databaseCreateNewShared,
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCost,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAckDatabase',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newDatabase-databaseThroughput-autoPilotRadio',
|
||||
throughputProvisionedRadioId: 'newDatabase-databaseThroughput-manualRadio',
|
||||
throughputModeRadioName: 'throughputModeRadioName',
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
autoPilotTiersList: autoPilotTiersList,
|
||||
selectedAutoPilotTier: selectedAutoPilotTier,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue
|
||||
}">
|
||||
</throughput-input>
|
||||
<p data-bind="visible: canRequestSupport">
|
||||
<!-- TODO: Replace link with call to the Azure Support blade --><a
|
||||
href="https://aka.ms/cosmosdbfeedback?subject=Cosmos%20DB%20More%20Throughput%20Request">Contact
|
||||
support</a> for more than <span data-bind="text: maxThroughputRUText"></span> RU/s.</p>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- Database provisioned throughput - End -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input type="submit" value="OK" class="btncreatecoll1">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add database inputs - End -->
|
||||
</script>
|
||||
49
src/Explorer/Panes/AddDatabasePane.test.ts
Normal file
49
src/Explorer/Panes/AddDatabasePane.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import Explorer from "../Explorer";
|
||||
import AddDatabasePane from "./AddDatabasePane";
|
||||
|
||||
jest.mock("../Tabs/NotebookTab");
|
||||
|
||||
describe("Add Database Pane", () => {
|
||||
describe("getSharedThroughputDefault()", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({
|
||||
documentClientUtility: null,
|
||||
notificationsClient: null,
|
||||
isEmulator: false
|
||||
});
|
||||
});
|
||||
|
||||
it("should be true if subscription type is Benefits", () => {
|
||||
explorer.subscriptionType(ViewModels.SubscriptionType.Benefits);
|
||||
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
|
||||
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if subscription type is EA", () => {
|
||||
explorer.subscriptionType(ViewModels.SubscriptionType.EA);
|
||||
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
|
||||
expect(addDatabasePane.getSharedThroughputDefault()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true if subscription type is Free", () => {
|
||||
explorer.subscriptionType(ViewModels.SubscriptionType.Free);
|
||||
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
|
||||
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be true if subscription type is Internal", () => {
|
||||
explorer.subscriptionType(ViewModels.SubscriptionType.Internal);
|
||||
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
|
||||
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be true if subscription type is PAYG", () => {
|
||||
explorer.subscriptionType(ViewModels.SubscriptionType.PAYG);
|
||||
const addDatabasePane = explorer.addDatabasePane as AddDatabasePane;
|
||||
expect(addDatabasePane.getSharedThroughputDefault()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
600
src/Explorer/Panes/AddDatabasePane.ts
Normal file
600
src/Explorer/Panes/AddDatabasePane.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import * as AddCollectionUtility from "../../Shared/AddCollectionUtility";
|
||||
import * as AutoPilotUtils from "../../Utils/AutoPilotUtils";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import * as ko from "knockout";
|
||||
import * as PricingUtils from "../../Utils/PricingUtils";
|
||||
import * as SharedConstants from "../../Shared/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import editable from "../../Common/EditableUtility";
|
||||
import EnvironmentUtility from "../../Common/EnvironmentUtility";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { AddDbUtilities } from "../../Shared/AddDatabaseUtility";
|
||||
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { CosmosClient } from "../../Common/CosmosClient";
|
||||
import { PlatformType } from "../../PlatformType";
|
||||
|
||||
export default class AddDatabasePane extends ContextualPaneBase implements ViewModels.AddDatabasePane {
|
||||
public defaultExperience: ko.Computed<string>;
|
||||
public databaseIdLabel: ko.Computed<string>;
|
||||
public databaseId: ko.Observable<string>;
|
||||
public databaseIdTooltipText: ko.Computed<string>;
|
||||
public databaseLevelThroughputTooltipText: ko.Computed<string>;
|
||||
public databaseCreateNewShared: ko.Observable<boolean>;
|
||||
public formErrorsDetails: ko.Observable<string>;
|
||||
public throughput: ViewModels.Editable<number>;
|
||||
public maxThroughputRU: ko.Observable<number>;
|
||||
public minThroughputRU: ko.Observable<number>;
|
||||
public maxThroughputRUText: ko.PureComputed<string>;
|
||||
public throughputRangeText: ko.Computed<string>;
|
||||
public throughputSpendAckText: ko.Observable<string>;
|
||||
public throughputSpendAck: ko.Observable<boolean>;
|
||||
public throughputSpendAckVisible: ko.Computed<boolean>;
|
||||
public requestUnitsUsageCost: ko.Computed<string>;
|
||||
public canRequestSupport: ko.PureComputed<boolean>;
|
||||
public costsVisible: ko.PureComputed<boolean>;
|
||||
public upsellMessage: ko.PureComputed<string>;
|
||||
public upsellMessageAriaLabel: ko.PureComputed<string>;
|
||||
public isAutoPilotSelected: ko.Observable<boolean>;
|
||||
public selectedAutoPilotTier: ko.Observable<DataModels.AutopilotTier>;
|
||||
public autoPilotTiersList: ko.ObservableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>;
|
||||
public maxAutoPilotThroughputSet: ko.Observable<number>;
|
||||
public autoPilotUsageCost: ko.Computed<string>;
|
||||
public canExceedMaximumValue: ko.PureComputed<boolean>;
|
||||
public hasAutoPilotV2FeatureFlag: ko.PureComputed<boolean>;
|
||||
public ruToolTipText: ko.Computed<string>;
|
||||
public isFreeTierAccount: ko.Computed<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title((this.container && this.container.addDatabaseText()) || "New Database");
|
||||
this.databaseId = ko.observable<string>();
|
||||
this.hasAutoPilotV2FeatureFlag = ko.pureComputed(() => this.container.hasAutoPilotV2FeatureFlag());
|
||||
this.ruToolTipText = ko.pureComputed(() => PricingUtils.getRuToolTipText(this.hasAutoPilotV2FeatureFlag()));
|
||||
|
||||
this.canExceedMaximumValue = ko.pureComputed(() => this.container.canExceedMaximumValue());
|
||||
|
||||
// TODO 388844: get defaults from parent frame
|
||||
this.databaseCreateNewShared = ko.observable<boolean>(this.getSharedThroughputDefault());
|
||||
|
||||
this.container.subscriptionType &&
|
||||
this.container.subscriptionType.subscribe(subscriptionType => {
|
||||
this.databaseCreateNewShared(this.getSharedThroughputDefault());
|
||||
});
|
||||
|
||||
this.databaseIdLabel = ko.computed<string>(() =>
|
||||
this.container.isPreferredApiCassandra() ? "Keyspace id" : "Database id"
|
||||
);
|
||||
this.databaseIdTooltipText = ko.computed<string>(() => {
|
||||
const isCassandraAccount: boolean = this.container.isPreferredApiCassandra();
|
||||
return `A ${isCassandraAccount ? "keyspace" : "database"} is a logical container of one or more ${
|
||||
isCassandraAccount ? "tables" : "collections"
|
||||
}`;
|
||||
});
|
||||
this.databaseLevelThroughputTooltipText = ko.computed<string>(() => {
|
||||
const isCassandraAccount: boolean = this.container.isPreferredApiCassandra();
|
||||
const databaseLabel: string = isCassandraAccount ? "keyspace" : "database";
|
||||
const collectionsLabel: string = isCassandraAccount ? "tables" : "collections";
|
||||
return `Provisioned throughput at the ${databaseLabel} level will be shared across all ${collectionsLabel} within the ${databaseLabel}.`;
|
||||
});
|
||||
|
||||
this.throughput = editable.observable<number>();
|
||||
this.maxThroughputRU = ko.observable<number>();
|
||||
this.minThroughputRU = ko.observable<number>();
|
||||
this.throughputSpendAckText = ko.observable<string>();
|
||||
this.throughputSpendAck = ko.observable<boolean>(false);
|
||||
this.selectedAutoPilotTier = ko.observable<DataModels.AutopilotTier>();
|
||||
this.autoPilotTiersList = ko.observableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>(
|
||||
AutoPilotUtils.getAvailableAutoPilotTiersOptions()
|
||||
);
|
||||
this.isAutoPilotSelected = ko.observable<boolean>(false);
|
||||
this.maxAutoPilotThroughputSet = ko.observable<number>(AutoPilotUtils.minAutoPilotThroughput);
|
||||
this.autoPilotUsageCost = ko.pureComputed<string>(() => {
|
||||
const autoPilot = this._isAutoPilotSelectedAndWhatTier();
|
||||
if (!autoPilot) {
|
||||
return "";
|
||||
}
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? PricingUtils.getAutoPilotV3SpendHtml(autoPilot.maxThroughput, true /* isDatabaseThroughput */)
|
||||
: PricingUtils.getAutoPilotV2SpendHtml(autoPilot.autopilotTier, true /* isDatabaseThroughput */);
|
||||
});
|
||||
this.throughputRangeText = ko.pureComputed<string>(() => {
|
||||
if (this.isAutoPilotSelected()) {
|
||||
return AutoPilotUtils.getAutoPilotHeaderText(this.hasAutoPilotV2FeatureFlag());
|
||||
}
|
||||
return `Throughput (${this.minThroughputRU().toLocaleString()} - ${this.maxThroughputRU().toLocaleString()} RU/s)`;
|
||||
});
|
||||
|
||||
this.requestUnitsUsageCost = ko.computed(() => {
|
||||
const offerThroughput: number = this.throughput();
|
||||
if (
|
||||
offerThroughput < this.minThroughputRU() ||
|
||||
(offerThroughput > this.maxThroughputRU() && !this.canExceedMaximumValue())
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const account = this.container.databaseAccount();
|
||||
if (!account) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const serverId = this.container.serverId();
|
||||
const regions =
|
||||
(account &&
|
||||
account.properties &&
|
||||
account.properties.readLocations &&
|
||||
account.properties.readLocations.length) ||
|
||||
1;
|
||||
const multimaster = (account && account.properties && account.properties.enableMultipleWriteLocations) || false;
|
||||
|
||||
let estimatedSpendAcknowledge: string;
|
||||
let estimatedSpend: string;
|
||||
if (!this.isAutoPilotSelected()) {
|
||||
estimatedSpend = PricingUtils.getEstimatedSpendHtml(
|
||||
offerThroughput,
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/
|
||||
);
|
||||
estimatedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
offerThroughput,
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/,
|
||||
this.isAutoPilotSelected()
|
||||
);
|
||||
} else {
|
||||
estimatedSpend = PricingUtils.getEstimatedAutoscaleSpendHtml(
|
||||
this.maxAutoPilotThroughputSet(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster
|
||||
);
|
||||
estimatedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
this.maxAutoPilotThroughputSet(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/,
|
||||
this.isAutoPilotSelected()
|
||||
);
|
||||
}
|
||||
// TODO: change throughputSpendAckText to be a computed value, instead of having this side effect
|
||||
this.throughputSpendAckText(estimatedSpendAcknowledge);
|
||||
return estimatedSpend;
|
||||
});
|
||||
|
||||
this.canRequestSupport = ko.pureComputed(() => {
|
||||
if (
|
||||
!this.container.isEmulator &&
|
||||
!this.container.isTryCosmosDBSubscription() &&
|
||||
this.container.getPlatformType() !== PlatformType.Portal
|
||||
) {
|
||||
const offerThroughput: number = this.throughput();
|
||||
return offerThroughput <= 100000;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.isFreeTierAccount = ko.computed<boolean>(() => {
|
||||
const databaseAccount = this.container && this.container.databaseAccount && this.container.databaseAccount();
|
||||
const isFreeTierAccount =
|
||||
databaseAccount && databaseAccount.properties && databaseAccount.properties.enableFreeTier;
|
||||
return isFreeTierAccount;
|
||||
});
|
||||
|
||||
this.maxThroughputRUText = ko.pureComputed(() => {
|
||||
return this.maxThroughputRU().toLocaleString();
|
||||
});
|
||||
|
||||
this.costsVisible = ko.pureComputed(() => {
|
||||
return !this.container.isEmulator;
|
||||
});
|
||||
|
||||
this.throughputSpendAckVisible = ko.pureComputed<boolean>(() => {
|
||||
const autoscaleThroughput = this.maxAutoPilotThroughputSet() * 1;
|
||||
if (!this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected()) {
|
||||
return autoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
}
|
||||
|
||||
const selectedThroughput: number = this.throughput();
|
||||
const maxRU: number = this.maxThroughputRU && this.maxThroughputRU();
|
||||
|
||||
const isMaxRUGreaterThanDefault: boolean = maxRU > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
const isThroughputSetGreaterThanDefault: boolean =
|
||||
selectedThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
|
||||
if (this.canExceedMaximumValue()) {
|
||||
return isThroughputSetGreaterThanDefault;
|
||||
}
|
||||
|
||||
return isThroughputSetGreaterThanDefault && isMaxRUGreaterThanDefault;
|
||||
});
|
||||
|
||||
this.databaseCreateNewShared.subscribe((useShared: boolean) => {
|
||||
this._updateThroughputLimitByDatabase();
|
||||
});
|
||||
|
||||
this.resetData();
|
||||
this.container.flight.subscribe(() => {
|
||||
this.resetData();
|
||||
});
|
||||
|
||||
this.upsellMessage = ko.pureComputed<string>(() => {
|
||||
return PricingUtils.getUpsellMessage(this.container.serverId());
|
||||
});
|
||||
|
||||
this.upsellMessageAriaLabel = ko.pureComputed<string>(() => {
|
||||
return `${this.upsellMessage()}. Click for more details`;
|
||||
});
|
||||
}
|
||||
|
||||
public onMoreDetailsKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.showErrorDetails();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public open() {
|
||||
super.open();
|
||||
this.resetData();
|
||||
const addDatabasePaneOpenMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
throughput: this.throughput(),
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane
|
||||
};
|
||||
const focusElement = document.getElementById("database-id");
|
||||
focusElement && focusElement.focus();
|
||||
TelemetryProcessor.trace(Action.CreateDatabase, ActionModifiers.Open, addDatabasePaneOpenMessage);
|
||||
}
|
||||
|
||||
public submit() {
|
||||
if (!this._isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const offerThroughput: number = this._computeOfferThroughput();
|
||||
|
||||
const addDatabasePaneStartMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
database: ko.toJS({
|
||||
id: this.databaseId(),
|
||||
shared: this.databaseCreateNewShared()
|
||||
}),
|
||||
offerThroughput,
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane
|
||||
};
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateDatabase, addDatabasePaneStartMessage);
|
||||
this.formErrors("");
|
||||
this.isExecuting(true);
|
||||
|
||||
const createDatabaseParameters: DataModels.RpParameters = {
|
||||
db: addDatabasePaneStartMessage.database.id,
|
||||
st: addDatabasePaneStartMessage.database.shared,
|
||||
offerThroughput: addDatabasePaneStartMessage.offerThroughput,
|
||||
sid: CosmosClient.subscriptionId(),
|
||||
rg: CosmosClient.resourceGroup(),
|
||||
dba: addDatabasePaneStartMessage.databaseAccountName
|
||||
};
|
||||
|
||||
const autopilotSettings = this._getAutopilotSettings();
|
||||
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
this._createKeyspace(createDatabaseParameters, autopilotSettings, startKey);
|
||||
} else if (this.container.isPreferredApiMongoDB() && EnvironmentUtility.isAadUser()) {
|
||||
this._createMongoDatabase(createDatabaseParameters, autopilotSettings, startKey);
|
||||
} else if (this.container.isPreferredApiGraph() && EnvironmentUtility.isAadUser()) {
|
||||
this._createGremlinDatabase(createDatabaseParameters, autopilotSettings, startKey);
|
||||
} else if (this.container.isPreferredApiDocumentDB() && EnvironmentUtility.isAadUser()) {
|
||||
this._createSqlDatabase(createDatabaseParameters, autopilotSettings, startKey);
|
||||
} else {
|
||||
this._createDatabase(offerThroughput, startKey);
|
||||
}
|
||||
}
|
||||
|
||||
private _createSqlDatabase(
|
||||
createDatabaseParameters: DataModels.RpParameters,
|
||||
autoPilotSettings: DataModels.RpOptions,
|
||||
startKey: number
|
||||
) {
|
||||
AddDbUtilities.createSqlDatabase(this.container.armEndpoint(), createDatabaseParameters, autoPilotSettings).then(
|
||||
() => {
|
||||
Promise.all([
|
||||
this.container.documentClientUtility.refreshCachedOffers(),
|
||||
this.container.documentClientUtility.refreshCachedResources()
|
||||
]).then(() => {
|
||||
this._onCreateDatabaseSuccess(createDatabaseParameters.offerThroughput, startKey);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _createMongoDatabase(
|
||||
createDatabaseParameters: DataModels.RpParameters,
|
||||
autoPilotSettings: DataModels.RpOptions,
|
||||
startKey: number
|
||||
) {
|
||||
AddDbUtilities.createMongoDatabaseWithARM(
|
||||
this.container.armEndpoint(),
|
||||
createDatabaseParameters,
|
||||
autoPilotSettings
|
||||
).then(() => {
|
||||
Promise.all([
|
||||
this.container.documentClientUtility.refreshCachedOffers(),
|
||||
this.container.documentClientUtility.refreshCachedResources()
|
||||
]).then(() => {
|
||||
this._onCreateDatabaseSuccess(createDatabaseParameters.offerThroughput, startKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _createGremlinDatabase(
|
||||
createDatabaseParameters: DataModels.RpParameters,
|
||||
autoPilotSettings: DataModels.RpOptions,
|
||||
startKey: number
|
||||
) {
|
||||
AddDbUtilities.createGremlinDatabase(
|
||||
this.container.armEndpoint(),
|
||||
createDatabaseParameters,
|
||||
autoPilotSettings
|
||||
).then(() => {
|
||||
Promise.all([
|
||||
this.container.documentClientUtility.refreshCachedOffers(),
|
||||
this.container.documentClientUtility.refreshCachedResources()
|
||||
]).then(() => {
|
||||
this._onCreateDatabaseSuccess(createDatabaseParameters.offerThroughput, startKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
this.databaseId("");
|
||||
this.databaseCreateNewShared(this.getSharedThroughputDefault());
|
||||
this.selectedAutoPilotTier(undefined);
|
||||
this.isAutoPilotSelected(false);
|
||||
this.maxAutoPilotThroughputSet(AutoPilotUtils.minAutoPilotThroughput);
|
||||
this._updateThroughputLimitByDatabase();
|
||||
this.throughputSpendAck(false);
|
||||
super.resetData();
|
||||
}
|
||||
|
||||
public getSharedThroughputDefault(): boolean {
|
||||
const subscriptionType: ViewModels.SubscriptionType =
|
||||
this.container.subscriptionType && this.container.subscriptionType();
|
||||
|
||||
if (subscriptionType === ViewModels.SubscriptionType.EA) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _createDatabase(offerThroughput: number, telemetryStartKey: number): void {
|
||||
const autoPilot: DataModels.AutoPilotCreationSettings = this._isAutoPilotSelectedAndWhatTier();
|
||||
const createRequest: DataModels.CreateDatabaseRequest = {
|
||||
databaseId: this.databaseId().trim(),
|
||||
offerThroughput,
|
||||
databaseLevelThroughput: this.databaseCreateNewShared(),
|
||||
autoPilot,
|
||||
hasAutoPilotV2FeatureFlag: this.hasAutoPilotV2FeatureFlag()
|
||||
};
|
||||
this.container.documentClientUtility.createDatabase(createRequest).then(
|
||||
(database: DataModels.Database) => {
|
||||
this._onCreateDatabaseSuccess(offerThroughput, telemetryStartKey);
|
||||
},
|
||||
(reason: any) => {
|
||||
this._onCreateDatabaseFailure(reason, offerThroughput, reason);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _createKeyspace(
|
||||
createDatabaseParameters: DataModels.RpParameters,
|
||||
autoPilotSettings: DataModels.RpOptions,
|
||||
startKey: number
|
||||
): void {
|
||||
if (EnvironmentUtility.isAadUser()) {
|
||||
this._createKeyspaceUsingRP(this.container.armEndpoint(), createDatabaseParameters, autoPilotSettings, startKey);
|
||||
} else {
|
||||
this._createKeyspaceUsingProxy(createDatabaseParameters.offerThroughput, startKey);
|
||||
}
|
||||
}
|
||||
|
||||
private _createKeyspaceUsingProxy(offerThroughput: number, telemetryStartKey: number): void {
|
||||
const provisionThroughputQueryPart: string = this.databaseCreateNewShared()
|
||||
? `AND cosmosdb_provisioned_throughput=${offerThroughput}`
|
||||
: "";
|
||||
const createKeyspaceQuery: string = `CREATE KEYSPACE ${this.databaseId().trim()} WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 3 } ${provisionThroughputQueryPart};`;
|
||||
(this.container.tableDataClient as CassandraAPIDataClient)
|
||||
.createKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
this.container,
|
||||
createKeyspaceQuery
|
||||
)
|
||||
.then(
|
||||
() => {
|
||||
this._onCreateDatabaseSuccess(offerThroughput, telemetryStartKey);
|
||||
},
|
||||
(reason: any) => {
|
||||
this._onCreateDatabaseFailure(reason, offerThroughput, telemetryStartKey);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _createKeyspaceUsingRP(
|
||||
armEndpoint: string,
|
||||
createKeyspaceParameters: DataModels.RpParameters,
|
||||
autoPilotSettings: DataModels.RpOptions,
|
||||
startKey: number
|
||||
): void {
|
||||
AddDbUtilities.createCassandraKeyspace(armEndpoint, createKeyspaceParameters, autoPilotSettings).then(() => {
|
||||
Promise.all([
|
||||
this.container.documentClientUtility.refreshCachedOffers(),
|
||||
this.container.documentClientUtility.refreshCachedResources()
|
||||
]).then(() => {
|
||||
this._onCreateDatabaseSuccess(createKeyspaceParameters.offerThroughput, startKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _onCreateDatabaseSuccess(offerThroughput: number, startKey: number): void {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
this.container.refreshAllDatabases();
|
||||
const addDatabasePaneSuccessMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
database: ko.toJS({
|
||||
id: this.databaseId(),
|
||||
shared: this.databaseCreateNewShared()
|
||||
}),
|
||||
offerThroughput: offerThroughput,
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane
|
||||
};
|
||||
TelemetryProcessor.traceSuccess(Action.CreateDatabase, addDatabasePaneSuccessMessage, startKey);
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
private _onCreateDatabaseFailure(reason: any, offerThroughput: number, startKey: number): void {
|
||||
this.isExecuting(false);
|
||||
const message = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(message[0].message);
|
||||
this.formErrorsDetails(message[0].message);
|
||||
const addDatabasePaneFailedMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
database: ko.toJS({
|
||||
id: this.databaseId(),
|
||||
shared: this.databaseCreateNewShared()
|
||||
}),
|
||||
offerThroughput: offerThroughput,
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
error: reason
|
||||
};
|
||||
TelemetryProcessor.traceFailure(Action.CreateDatabase, addDatabasePaneFailedMessage, startKey);
|
||||
}
|
||||
|
||||
private _getThroughput(): number {
|
||||
const throughput: number = this.throughput();
|
||||
return isNaN(throughput) ? 0 : Number(throughput);
|
||||
}
|
||||
|
||||
private _computeOfferThroughput(): number {
|
||||
return this.isAutoPilotSelected() ? undefined : this._getThroughput();
|
||||
}
|
||||
|
||||
private _isValid(): boolean {
|
||||
// TODO add feature flag that disables validation for customers with custom accounts
|
||||
if (this.isAutoPilotSelected()) {
|
||||
const autoPilot = this._isAutoPilotSelectedAndWhatTier();
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() &&
|
||||
(!autoPilot ||
|
||||
!autoPilot.maxThroughput ||
|
||||
!AutoPilotUtils.isValidAutoPilotThroughput(autoPilot.maxThroughput))) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() &&
|
||||
(!autoPilot || !autoPilot.autopilotTier || !AutoPilotUtils.isValidAutoPilotTier(autoPilot.autopilotTier)))
|
||||
) {
|
||||
this.formErrors(
|
||||
!this.hasAutoPilotV2FeatureFlag()
|
||||
? `Please enter a value greater than ${AutoPilotUtils.minAutoPilotThroughput} for autopilot throughput`
|
||||
: "Please select an Autopilot tier from the list."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const throughput = this._getThroughput();
|
||||
|
||||
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !this.throughputSpendAck()) {
|
||||
this.formErrors(`Please acknowledge the estimated daily spend.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const autoscaleThroughput = this.maxAutoPilotThroughputSet() * 1;
|
||||
|
||||
if (
|
||||
!this.hasAutoPilotV2FeatureFlag() &&
|
||||
this.isAutoPilotSelected() &&
|
||||
autoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K &&
|
||||
!this.throughputSpendAck()
|
||||
) {
|
||||
this.formErrors(`Please acknowledge the estimated monthly spend.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _isAutoPilotSelectedAndWhatTier(): DataModels.AutoPilotCreationSettings {
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected() && this.maxAutoPilotThroughputSet()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected() && this.selectedAutoPilotTier())
|
||||
) {
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? {
|
||||
maxThroughput: this.maxAutoPilotThroughputSet() * 1
|
||||
}
|
||||
: { autopilotTier: this.selectedAutoPilotTier() };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getAutopilotSettings(): DataModels.RpOptions {
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected() && this.maxAutoPilotThroughputSet()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected() && this.selectedAutoPilotTier())
|
||||
) {
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? {
|
||||
[Constants.HttpHeaders.autoPilotThroughput]: `{ "maxThroughput": ${this.maxAutoPilotThroughputSet() * 1} }`
|
||||
}
|
||||
: { [Constants.HttpHeaders.autoPilotTier]: this.selectedAutoPilotTier().toString() };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _updateThroughputLimitByDatabase() {
|
||||
const subscriptionType = this.container.subscriptionType();
|
||||
const flight = this.container.flight();
|
||||
const throughputDefaults: AddCollectionUtility.ThroughputDefaults = AddCollectionUtility.Utilities.getDefaultThroughput(
|
||||
flight,
|
||||
subscriptionType
|
||||
);
|
||||
|
||||
this.throughput(throughputDefaults.shared);
|
||||
this.maxThroughputRU(throughputDefaults.unlimitedmax);
|
||||
this.minThroughputRU(throughputDefaults.unlimitedmin);
|
||||
}
|
||||
}
|
||||
33
src/Explorer/Panes/BrowseQueriesPane.html
Normal file
33
src/Explorer/Panes/BrowseQueriesPane.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="browsequeriespane">
|
||||
<!-- Save Query form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<div class="paneContentContainer">
|
||||
<!-- Save Query header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Query header - End -->
|
||||
|
||||
<!-- Save Query inputs - Start -->
|
||||
<div class="paneMainContent"><div class="pkPadding" data-bind="react: queriesGridComponentAdapter"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Query form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
104
src/Explorer/Panes/BrowseQueriesPane.ts
Normal file
104
src/Explorer/Panes/BrowseQueriesPane.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas } from "../../Common/Constants";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import { QueriesGridComponentAdapter } from "../Controls/QueriesGridReactComponent/QueriesGridComponentAdapter";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export class BrowseQueriesPane extends ContextualPaneBase implements ViewModels.BrowseQueriesPane {
|
||||
public queriesGridComponentAdapter: QueriesGridComponentAdapter;
|
||||
public canSaveQueries: ko.Computed<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Open Saved Queries");
|
||||
this.resetData();
|
||||
this.canSaveQueries = this.container && this.container.canSaveQueries;
|
||||
this.queriesGridComponentAdapter = new QueriesGridComponentAdapter(this.container);
|
||||
}
|
||||
|
||||
public open() {
|
||||
super.open();
|
||||
this.queriesGridComponentAdapter.forceRender();
|
||||
}
|
||||
|
||||
public close() {
|
||||
super.close();
|
||||
this.queriesGridComponentAdapter.forceRender();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
// override default behavior because this is not a form
|
||||
}
|
||||
|
||||
public setupQueries = async (src: any, event: MouseEvent): Promise<void> => {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.SetupSavedQueries, {
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
try {
|
||||
this.isExecuting(true);
|
||||
await this.container.queriesClient.setupQueriesCollection();
|
||||
this.container.refreshAllDatabases().done(() => this.queriesGridComponentAdapter.forceRender());
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.SetupSavedQueries,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} catch (error) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.SetupSavedQueries,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.formErrors("Failed to setup a collection for saved queries");
|
||||
this.formErrors(`Failed to setup a collection for saved queries: ${JSON.stringify(error)}`);
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
public loadSavedQuery = (savedQuery: DataModels.Query): void => {
|
||||
const selectedCollection: ViewModels.Collection = this.container && this.container.findSelectedCollection();
|
||||
if (!selectedCollection) {
|
||||
// should never get into this state because this pane is only accessible through the query tab
|
||||
Logger.logError("No collection was selected", "BrowseQueriesPane.loadSavedQuery");
|
||||
return;
|
||||
} else if (this.container.isPreferredApiMongoDB()) {
|
||||
selectedCollection.onNewMongoQueryClick(selectedCollection, null);
|
||||
} else {
|
||||
selectedCollection.onNewQueryClick(selectedCollection, null);
|
||||
}
|
||||
const queryTab: ViewModels.QueryTab = this.container.findActiveTab() as ViewModels.QueryTab;
|
||||
queryTab.tabTitle(savedQuery.queryName);
|
||||
queryTab.tabPath(`${selectedCollection.databaseId}>${selectedCollection.id()}>${savedQuery.queryName}`);
|
||||
queryTab.initialEditorContent(savedQuery.query);
|
||||
queryTab.sqlQueryEditorContent(savedQuery.query);
|
||||
TelemetryProcessor.trace(Action.LoadSavedQuery, ActionModifiers.Mark, {
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
queryName: savedQuery.queryName,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
this.close();
|
||||
};
|
||||
}
|
||||
333
src/Explorer/Panes/CassandraAddCollectionPane.html
Normal file
333
src/Explorer/Panes/CassandraAddCollectionPane.html
Normal file
@@ -0,0 +1,333 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="cassandraaddcollectionpane">
|
||||
<!-- Add Cassandra collection form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
role="dialog"
|
||||
aria-label="Add Table"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Add Cassandra collection header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Cassandra collection header - End -->
|
||||
<!-- Add Cassandra collection errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Cassandra collection errors - End -->
|
||||
<div class="paneMainContent">
|
||||
<div class="seconddivpadding">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span> Keyspace name
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext infoTooltipWidth"
|
||||
>Select an existing keyspace or enter a new keyspace id.</span
|
||||
>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="createNewDatabaseOrUseExisting">
|
||||
<input
|
||||
class="createNewDatabaseOrUseExistingRadio"
|
||||
aria-label="Create new keyspace"
|
||||
name="databaseType"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="keyspaceCreateNew"
|
||||
data-test="addCollection-newDatabase"
|
||||
tabindex="0"
|
||||
data-bind="checked: keyspaceCreateNew, checkedValue: true, attr: { 'aria-checked': keyspaceCreateNew() ? 'true' : 'false' }"
|
||||
/>
|
||||
<span class="createNewDatabaseOrUseExistingSpace" for="keyspaceCreateNew">Create new</span>
|
||||
|
||||
<input
|
||||
class="createNewDatabaseOrUseExistingRadio"
|
||||
aria-label="Use existing keyspace"
|
||||
name="databaseType"
|
||||
type="radio"
|
||||
role="radio"
|
||||
id="keyspaceUseExisting"
|
||||
data-test="addCollection-existingDatabase"
|
||||
tabindex="0"
|
||||
data-bind="checked: keyspaceCreateNew, checkedValue: false, attr: { 'aria-checked': !keyspaceCreateNew() ? 'true' : 'false' }"
|
||||
/>
|
||||
<span class="createNewDatabaseOrUseExistingSpace" for="keyspaceUseExisting">Use existing</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="keyspace-id"
|
||||
data-test="addCollection-keyspaceId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
placeholder="Type a new keyspace id"
|
||||
size="40"
|
||||
class="collid"
|
||||
data-bind="visible: keyspaceCreateNew, textInput: keyspaceId, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Keyspace id"
|
||||
aria-required="true"
|
||||
autofocus
|
||||
/>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
aria-required="true"
|
||||
autocomplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
list="keyspacesList"
|
||||
placeholder="Choose existing keyspace id"
|
||||
size="40"
|
||||
class="collid"
|
||||
data-bind="visible: !keyspaceCreateNew(), textInput: keyspaceId, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Keyspace id"
|
||||
/>
|
||||
|
||||
<datalist id="keyspacesList" data-bind="foreach: container.nonSystemDatabases">
|
||||
<option data-bind="value: $data.id"> </option>
|
||||
</datalist>
|
||||
|
||||
<div
|
||||
class="databaseProvision"
|
||||
aria-label="New database provision support"
|
||||
data-bind="visible: keyspaceCreateNew"
|
||||
>
|
||||
<input
|
||||
tabindex="0"
|
||||
type="checkbox"
|
||||
id="keyspaceSharedThroughput"
|
||||
title="Provision shared throughput"
|
||||
data-bind="checked: keyspaceHasSharedOffer"
|
||||
/>
|
||||
<span class="databaseProvisionText" for="keyspaceSharedThroughput">Provision keyspace throughput</span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext provisionDatabaseThroughput"
|
||||
>Provisioned throughput at the keyspace level will be shared across unlimited number of tables within
|
||||
the keyspace</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 1 -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: keyspaceCreateNew() && keyspaceHasSharedOffer()">
|
||||
<throughput-input-autopilot-v3
|
||||
params="{
|
||||
testId: 'cassandraThroughputValue-v3-shared',
|
||||
value: keyspaceThroughput,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: keyspaceHasSharedOffer,
|
||||
label: sharedThroughputRangeText,
|
||||
ariaLabel: sharedThroughputRangeText,
|
||||
requestUnitsUsageCost: requestUnitsUsageCostShared,
|
||||
spendAckChecked: sharedThroughputSpendAck,
|
||||
spendAckId: 'sharedThroughputSpendAck-v3-shared',
|
||||
spendAckText: sharedThroughputSpendAckText,
|
||||
spendAckVisible: sharedThroughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newKeyspace-databaseThroughput-autoPilotRadio-v3-shared',
|
||||
throughputProvisionedRadioId: 'newKeyspace-databaseThroughput-manualRadio-v3-shared',
|
||||
isAutoPilotSelected: isSharedAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: sharedAutoPilotThroughput,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
costsVisible: costsVisible,
|
||||
showAutoPilot: !isFreeTierAccount()
|
||||
}"
|
||||
>
|
||||
</throughput-input-autopilot-v3>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: keyspaceCreateNew() && keyspaceHasSharedOffer()">
|
||||
<throughput-input
|
||||
params="{
|
||||
testId: 'cassandraThroughputValue-v2-shared',
|
||||
value: keyspaceThroughput,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: keyspaceHasSharedOffer,
|
||||
label: sharedThroughputRangeText,
|
||||
ariaLabel: sharedThroughputRangeText,
|
||||
requestUnitsUsageCost: requestUnitsUsageCostShared,
|
||||
spendAckChecked: sharedThroughputSpendAck,
|
||||
spendAckId: 'sharedThroughputSpendAck-v2-shared',
|
||||
spendAckText: sharedThroughputSpendAckText,
|
||||
spendAckVisible: sharedThroughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newKeyspace-databaseThroughput-autoPilotRadio-v2-shared',
|
||||
throughputProvisionedRadioId: 'newKeyspace-databaseThroughput-manualRadio-v2-shared',
|
||||
isAutoPilotSelected: isSharedAutoPilotSelected,
|
||||
autoPilotTiersList: sharedAutoPilotTiersList,
|
||||
costsVisible: costsVisible,
|
||||
selectedAutoPilotTier: selectedSharedAutoPilotTier,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue
|
||||
}"
|
||||
>
|
||||
</throughput-input>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<div class="seconddivpadding">
|
||||
<p>
|
||||
<span class="mandatoryStar">*</span> Enter CQL command to create the table.
|
||||
<a href="https://aka.ms/cassandra-create-table" target="_blank">Learn More</a>
|
||||
</p>
|
||||
<div data-bind="text: createTableQuery" style="float:left; padding-top:3px; padding-right:3px;"></div>
|
||||
<input
|
||||
type="text"
|
||||
data-test="addCollection-tableId"
|
||||
aria-required="true"
|
||||
autocomplete="off"
|
||||
pattern="[^/?#\\]*[^/?# \\]"
|
||||
title="May not end with space nor contain characters '\' '/' '#' '?'"
|
||||
data-test="addCollection-tableId"
|
||||
placeholder="Enter tableId"
|
||||
size="20"
|
||||
class="textfontclr"
|
||||
data-bind="value: tableId"
|
||||
style="margin-bottom: 5px;"
|
||||
/>
|
||||
<textarea
|
||||
id="editor-area"
|
||||
rows="15"
|
||||
aria-label="Table Schema"
|
||||
data-bind="value: userTableQuery"
|
||||
style="height:125px; width: calc(100% - 80px); resize: vertical;"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="seconddivpadding" data-bind="visible: keyspaceHasSharedOffer() && !keyspaceCreateNew()">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="tableSharedThroughput"
|
||||
title="Provision dedicated throughput for this table"
|
||||
data-bind="checked: dedicateTableThroughput"
|
||||
/>
|
||||
<span for="tableSharedThroughput">Provision dedicated throughput for this table</span>
|
||||
<span class="leftAlignInfoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext sharedCollectionThroughputTooltipWidth"
|
||||
>You can optionally provision dedicated throughput for a table within a keyspace that has throughput
|
||||
provisioned. This dedicated throughput amount will not be shared with other tables in the keyspace and
|
||||
does not count towards the throughput you provisioned for the keyspace. This throughput amount will be
|
||||
billed in addition to the throughput amount you provisioned at the keyspace level.</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 2 -->
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && !hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: !keyspaceHasSharedOffer() || dedicateTableThroughput()">
|
||||
<throughput-input-autopilot-v3
|
||||
params="{
|
||||
testId: 'cassandraSharedThroughputValue-v3-dedicated',
|
||||
value: throughput,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: !keyspaceHasSharedOffer() || dedicateTableThroughput(),
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCostDedicated,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAckCassandra-v3-dedicated',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newKeyspace-containerThroughput-autoPilotRadio-v3-dedicated',
|
||||
throughputProvisionedRadioId: 'newKeyspace-containerThroughput-manualRadio-v3-dedicated',
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
maxAutoPilotThroughputSet: selectedAutoPilotThroughput,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
canExceedMaximumValue: canExceedMaximumValue,
|
||||
overrideWithAutoPilotSettings: false,
|
||||
overrideWithProvisionedThroughputSettings: false
|
||||
}"
|
||||
>
|
||||
</throughput-input-autopilot-v3>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
|
||||
<!-- ko if: hasAutoPilotV2FeatureFlag && hasAutoPilotV2FeatureFlag() -->
|
||||
<div data-bind="visible: !keyspaceHasSharedOffer() || dedicateTableThroughput()">
|
||||
<throughput-input
|
||||
params="{
|
||||
testId: 'cassandraSharedThroughputValue-v2-dedicated',
|
||||
value: throughput,
|
||||
minimum: minThroughputRU,
|
||||
maximum: maxThroughputRU,
|
||||
isEnabled: !keyspaceHasSharedOffer() || dedicateTableThroughput(),
|
||||
label: throughputRangeText,
|
||||
ariaLabel: throughputRangeText,
|
||||
costsVisible: costsVisible,
|
||||
requestUnitsUsageCost: requestUnitsUsageCostDedicated,
|
||||
spendAckChecked: throughputSpendAck,
|
||||
spendAckId: 'throughputSpendAckCassandra-v2-dedicated',
|
||||
spendAckText: throughputSpendAckText,
|
||||
spendAckVisible: throughputSpendAckVisible,
|
||||
showAsMandatory: true,
|
||||
infoBubbleText: ruToolTipText,
|
||||
throughputAutoPilotRadioId: 'newKeyspace-containerThroughput-autoPilotRadio-v2-dedicated',
|
||||
throughputProvisionedRadioId: 'newKeyspace-containerThroughput-manualRadio-v2-dedicated',
|
||||
isAutoPilotSelected: isAutoPilotSelected,
|
||||
autoPilotTiersList: autoPilotTiersList,
|
||||
selectedAutoPilotTier: selectedAutoPilotTier,
|
||||
autoPilotUsageCost: autoPilotUsageCost,
|
||||
showAutoPilot: false,
|
||||
canExceedMaximumValue: canExceedMaximumValue
|
||||
}"
|
||||
>
|
||||
</throughput-input>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input type="submit" data-test="addCollection-createCollection" value="OK" class="btncreatecoll1" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Add Cassandra collection form - End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" alt="loading indicator" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
615
src/Explorer/Panes/CassandraAddCollectionPane.ts
Normal file
615
src/Explorer/Panes/CassandraAddCollectionPane.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import * as _ from "underscore";
|
||||
import * as AddCollectionUtility from "../../Shared/AddCollectionUtility";
|
||||
import * as AutoPilotUtils from "../../Utils/AutoPilotUtils";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ko from "knockout";
|
||||
import * as PricingUtils from "../../Utils/PricingUtils";
|
||||
import * as SharedConstants from "../../Shared/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { HashMap } from "../../Common/HashMap";
|
||||
|
||||
export default class CassandraAddCollectionPane extends ContextualPaneBase
|
||||
implements ViewModels.CassandraAddCollectionPane {
|
||||
public createTableQuery: ko.Observable<string>;
|
||||
public keyspaceId: ko.Observable<string>;
|
||||
public maxThroughputRU: ko.Observable<number>;
|
||||
public minThroughputRU: ko.Observable<number>;
|
||||
public tableId: ko.Observable<string>;
|
||||
public throughput: ko.Observable<number>;
|
||||
public throughputRangeText: ko.Computed<string>;
|
||||
public sharedThroughputRangeText: ko.Computed<string>;
|
||||
public userTableQuery: ko.Observable<string>;
|
||||
public requestUnitsUsageCostDedicated: ko.Computed<string>;
|
||||
public requestUnitsUsageCostShared: ko.Computed<string>;
|
||||
public costsVisible: ko.PureComputed<boolean>;
|
||||
public keyspaceHasSharedOffer: ko.Observable<boolean>;
|
||||
public keyspaceIds: ko.ObservableArray<string>;
|
||||
public keyspaceThroughput: ko.Observable<number>;
|
||||
public keyspaceCreateNew: ko.Observable<boolean>;
|
||||
public dedicateTableThroughput: ko.Observable<boolean>;
|
||||
public canRequestSupport: ko.PureComputed<boolean>;
|
||||
public throughputSpendAckText: ko.Observable<string>;
|
||||
public throughputSpendAck: ko.Observable<boolean>;
|
||||
public sharedThroughputSpendAck: ko.Observable<boolean>;
|
||||
public sharedThroughputSpendAckText: ko.Observable<string>;
|
||||
public isAutoPilotSelected: ko.Observable<boolean>;
|
||||
public selectedAutoPilotTier: ko.Observable<DataModels.AutopilotTier>;
|
||||
public selectedSharedAutoPilotTier: ko.Observable<DataModels.AutopilotTier>;
|
||||
public autoPilotTiersList: ko.ObservableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>;
|
||||
public sharedAutoPilotTiersList: ko.ObservableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>;
|
||||
public isSharedAutoPilotSelected: ko.Observable<boolean>;
|
||||
public selectedAutoPilotThroughput: ko.Observable<number>;
|
||||
public sharedAutoPilotThroughput: ko.Observable<number>;
|
||||
public autoPilotUsageCost: ko.Computed<string>;
|
||||
public sharedThroughputSpendAckVisible: ko.Computed<boolean>;
|
||||
public throughputSpendAckVisible: ko.Computed<boolean>;
|
||||
public canExceedMaximumValue: ko.PureComputed<boolean>;
|
||||
public hasAutoPilotV2FeatureFlag: ko.PureComputed<boolean>;
|
||||
public isFreeTierAccount: ko.Computed<boolean>;
|
||||
public ruToolTipText: ko.Computed<string>;
|
||||
|
||||
private keyspaceOffers: HashMap<DataModels.Offer>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Add Table");
|
||||
this.createTableQuery = ko.observable<string>("CREATE TABLE ");
|
||||
this.keyspaceCreateNew = ko.observable<boolean>(true);
|
||||
this.hasAutoPilotV2FeatureFlag = ko.pureComputed(() => this.container.hasAutoPilotV2FeatureFlag());
|
||||
this.ruToolTipText = ko.pureComputed(() => PricingUtils.getRuToolTipText(this.hasAutoPilotV2FeatureFlag()));
|
||||
this.keyspaceOffers = new HashMap<DataModels.Offer>();
|
||||
this.keyspaceIds = ko.observableArray<string>();
|
||||
this.keyspaceHasSharedOffer = ko.observable<boolean>(false);
|
||||
this.keyspaceThroughput = ko.observable<number>();
|
||||
this.keyspaceId = ko.observable<string>("");
|
||||
this.keyspaceId.subscribe((keyspaceId: string) => {
|
||||
if (this.keyspaceIds.indexOf(keyspaceId) >= 0) {
|
||||
this.keyspaceHasSharedOffer(this.keyspaceOffers.has(keyspaceId));
|
||||
}
|
||||
});
|
||||
this.keyspaceId.extend({ rateLimit: 100 });
|
||||
this.dedicateTableThroughput = ko.observable<boolean>(false);
|
||||
|
||||
const flight = this.container.flight();
|
||||
const subscriptionType = this.container.subscriptionType();
|
||||
const throughputDefaults = AddCollectionUtility.Utilities.getDefaultThroughput(flight, subscriptionType);
|
||||
this.maxThroughputRU = ko.observable<number>(throughputDefaults.unlimitedmax);
|
||||
this.minThroughputRU = ko.observable<number>(throughputDefaults.unlimitedmin);
|
||||
|
||||
this.canExceedMaximumValue = ko.pureComputed(() => this.container.canExceedMaximumValue());
|
||||
|
||||
this.isFreeTierAccount = ko.computed<boolean>(() => {
|
||||
const databaseAccount = this.container && this.container.databaseAccount && this.container.databaseAccount();
|
||||
const isFreeTierAccount =
|
||||
databaseAccount && databaseAccount.properties && databaseAccount.properties.enableFreeTier;
|
||||
return isFreeTierAccount;
|
||||
});
|
||||
|
||||
this.tableId = ko.observable<string>("");
|
||||
this.selectedAutoPilotTier = ko.observable<DataModels.AutopilotTier>();
|
||||
this.selectedSharedAutoPilotTier = ko.observable<DataModels.AutopilotTier>();
|
||||
this.isAutoPilotSelected = ko.observable<boolean>(false);
|
||||
this.isSharedAutoPilotSelected = ko.observable<boolean>(false);
|
||||
this.selectedAutoPilotThroughput = ko.observable<number>();
|
||||
this.sharedAutoPilotThroughput = ko.observable<number>();
|
||||
this.throughput = ko.observable<number>();
|
||||
this.throughputRangeText = ko.pureComputed<string>(() => {
|
||||
const enableAutoPilot = this.isAutoPilotSelected();
|
||||
if (!enableAutoPilot) {
|
||||
return `Throughput (${this.minThroughputRU().toLocaleString()} - ${this.maxThroughputRU().toLocaleString()} RU/s)`;
|
||||
}
|
||||
return AutoPilotUtils.getAutoPilotHeaderText(this.hasAutoPilotV2FeatureFlag());
|
||||
});
|
||||
this.sharedThroughputRangeText = ko.pureComputed<string>(() => {
|
||||
if (this.isSharedAutoPilotSelected()) {
|
||||
return AutoPilotUtils.getAutoPilotHeaderText(this.hasAutoPilotV2FeatureFlag());
|
||||
}
|
||||
return `Throughput (${this.minThroughputRU().toLocaleString()} - ${this.maxThroughputRU().toLocaleString()} RU/s)`;
|
||||
});
|
||||
this.userTableQuery = ko.observable<string>("(userid int, name text, email text, PRIMARY KEY (userid))");
|
||||
this.keyspaceId.subscribe(keyspaceId => {
|
||||
this.createTableQuery(`CREATE TABLE ${keyspaceId}.`);
|
||||
});
|
||||
|
||||
this.throughputSpendAckText = ko.observable<string>();
|
||||
this.throughputSpendAck = ko.observable<boolean>(false);
|
||||
this.sharedThroughputSpendAck = ko.observable<boolean>(false);
|
||||
this.sharedThroughputSpendAckText = ko.observable<string>();
|
||||
|
||||
this.resetData();
|
||||
|
||||
this.container.flight.subscribe(() => {
|
||||
this.resetData();
|
||||
});
|
||||
|
||||
this.requestUnitsUsageCostDedicated = ko.computed(() => {
|
||||
const account = this.container.databaseAccount();
|
||||
if (!account) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const serverId = this.container.serverId();
|
||||
const regions =
|
||||
(account &&
|
||||
account.properties &&
|
||||
account.properties.readLocations &&
|
||||
account.properties.readLocations.length) ||
|
||||
1;
|
||||
const multimaster = (account && account.properties && account.properties.enableMultipleWriteLocations) || false;
|
||||
const offerThroughput: number = this.throughput();
|
||||
let estimatedSpend: string;
|
||||
let estimatedDedicatedSpendAcknowledge: string;
|
||||
if (!this.isAutoPilotSelected()) {
|
||||
estimatedSpend = PricingUtils.getEstimatedSpendHtml(
|
||||
offerThroughput,
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/
|
||||
);
|
||||
estimatedDedicatedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
offerThroughput,
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/,
|
||||
this.isAutoPilotSelected()
|
||||
);
|
||||
} else {
|
||||
estimatedSpend = PricingUtils.getEstimatedAutoscaleSpendHtml(
|
||||
this.selectedAutoPilotThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster
|
||||
);
|
||||
estimatedDedicatedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
this.selectedAutoPilotThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/,
|
||||
this.isAutoPilotSelected()
|
||||
);
|
||||
}
|
||||
this.throughputSpendAckText(estimatedDedicatedSpendAcknowledge);
|
||||
return estimatedSpend;
|
||||
});
|
||||
|
||||
this.requestUnitsUsageCostShared = ko.computed(() => {
|
||||
const account = this.container.databaseAccount();
|
||||
if (!account) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const serverId = this.container.serverId();
|
||||
const regions =
|
||||
(account &&
|
||||
account.properties &&
|
||||
account.properties.readLocations &&
|
||||
account.properties.readLocations.length) ||
|
||||
1;
|
||||
const multimaster = (account && account.properties && account.properties.enableMultipleWriteLocations) || false;
|
||||
let estimatedSpend: string;
|
||||
let estimatedSharedSpendAcknowledge: string;
|
||||
if (!this.isSharedAutoPilotSelected()) {
|
||||
estimatedSpend = PricingUtils.getEstimatedSpendHtml(
|
||||
this.keyspaceThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/
|
||||
);
|
||||
estimatedSharedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
this.keyspaceThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/,
|
||||
this.isSharedAutoPilotSelected()
|
||||
);
|
||||
} else {
|
||||
estimatedSpend = PricingUtils.getEstimatedAutoscaleSpendHtml(
|
||||
this.sharedAutoPilotThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster
|
||||
);
|
||||
estimatedSharedSpendAcknowledge = PricingUtils.getEstimatedSpendAcknowledgeString(
|
||||
this.sharedAutoPilotThroughput(),
|
||||
serverId,
|
||||
regions,
|
||||
multimaster,
|
||||
false /*rupmEnabled*/,
|
||||
this.isSharedAutoPilotSelected()
|
||||
);
|
||||
}
|
||||
this.sharedThroughputSpendAckText(estimatedSharedSpendAcknowledge);
|
||||
return estimatedSpend;
|
||||
});
|
||||
|
||||
this.costsVisible = ko.pureComputed(() => {
|
||||
return !this.container.isEmulator;
|
||||
});
|
||||
|
||||
this.canRequestSupport = ko.pureComputed(() => {
|
||||
if (!this.container.isEmulator && !this.container.isTryCosmosDBSubscription()) {
|
||||
const offerThroughput: number = this.throughput();
|
||||
return offerThroughput <= 100000;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
this.sharedThroughputSpendAckVisible = ko.computed<boolean>(() => {
|
||||
const autoscaleThroughput = this.sharedAutoPilotThroughput() * 1;
|
||||
if (!this.hasAutoPilotV2FeatureFlag() && this.isSharedAutoPilotSelected()) {
|
||||
return autoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
}
|
||||
|
||||
return this.keyspaceThroughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
});
|
||||
|
||||
this.throughputSpendAckVisible = ko.pureComputed<boolean>(() => {
|
||||
const autoscaleThroughput = this.selectedAutoPilotThroughput() * 1;
|
||||
if (!this.hasAutoPilotV2FeatureFlag() && this.isAutoPilotSelected()) {
|
||||
return autoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
}
|
||||
|
||||
return this.throughput() > SharedConstants.CollectionCreation.DefaultCollectionRUs100K;
|
||||
});
|
||||
|
||||
if (!!this.container) {
|
||||
const updateKeyspaceIds: (keyspaces: ViewModels.Database[]) => void = (
|
||||
newKeyspaceIds: ViewModels.Database[]
|
||||
): void => {
|
||||
const cachedKeyspaceIdsList = _.map(newKeyspaceIds, (keyspace: ViewModels.Database) => {
|
||||
if (keyspace && keyspace.offer && !!keyspace.offer()) {
|
||||
this.keyspaceOffers.set(keyspace.id(), keyspace.offer());
|
||||
} else if (keyspace && keyspace.isDatabaseShared && keyspace.isDatabaseShared()) {
|
||||
keyspace.readSettings();
|
||||
}
|
||||
return keyspace.id();
|
||||
});
|
||||
this.keyspaceIds(cachedKeyspaceIdsList);
|
||||
};
|
||||
this.container.nonSystemDatabases.subscribe((newDatabases: ViewModels.Database[]) =>
|
||||
updateKeyspaceIds(newDatabases)
|
||||
);
|
||||
updateKeyspaceIds(this.container.nonSystemDatabases());
|
||||
}
|
||||
|
||||
this.autoPilotTiersList = ko.observableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>(
|
||||
AutoPilotUtils.getAvailableAutoPilotTiersOptions()
|
||||
);
|
||||
this.sharedAutoPilotTiersList = ko.observableArray<ViewModels.DropdownOption<DataModels.AutopilotTier>>(
|
||||
AutoPilotUtils.getAvailableAutoPilotTiersOptions()
|
||||
);
|
||||
|
||||
this.autoPilotUsageCost = ko.pureComputed<string>(() => {
|
||||
const autoPilot = this._getAutoPilot();
|
||||
if (!autoPilot) {
|
||||
return "";
|
||||
}
|
||||
const isDatabaseThroughput: boolean = this.keyspaceCreateNew();
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? PricingUtils.getAutoPilotV3SpendHtml(autoPilot.maxThroughput, isDatabaseThroughput)
|
||||
: PricingUtils.getAutoPilotV2SpendHtml(autoPilot.autopilotTier, isDatabaseThroughput);
|
||||
});
|
||||
}
|
||||
|
||||
public decreaseThroughput() {
|
||||
let offerThroughput: number = this.throughput();
|
||||
|
||||
if (offerThroughput > this.minThroughputRU()) {
|
||||
offerThroughput -= 100;
|
||||
this.throughput(offerThroughput);
|
||||
}
|
||||
}
|
||||
|
||||
public increaseThroughput() {
|
||||
let offerThroughput: number = this.throughput();
|
||||
|
||||
if (offerThroughput < this.maxThroughputRU()) {
|
||||
offerThroughput += 100;
|
||||
this.throughput(offerThroughput);
|
||||
}
|
||||
}
|
||||
|
||||
public open() {
|
||||
super.open();
|
||||
const addCollectionPaneOpenMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collection: ko.toJS({
|
||||
id: this.tableId(),
|
||||
storage: Constants.BackendDefaults.multiPartitionStorageInGb,
|
||||
offerThroughput: this.throughput(),
|
||||
partitionKey: "",
|
||||
databaseId: this.keyspaceId(),
|
||||
rupm: false
|
||||
}),
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
storage: "u",
|
||||
throughput: this.throughput(),
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane
|
||||
};
|
||||
const focusElement = document.getElementById("keyspace-id");
|
||||
focusElement && focusElement.focus();
|
||||
TelemetryProcessor.trace(Action.CreateCollection, ActionModifiers.Open, addCollectionPaneOpenMessage);
|
||||
}
|
||||
|
||||
public submit() {
|
||||
if (!this._isValid()) {
|
||||
return;
|
||||
}
|
||||
this.isExecuting(true);
|
||||
const autoPilotCommand = `cosmosdb_autoscale_max_throughput`;
|
||||
let createTableAndKeyspacePromise: Q.Promise<any>;
|
||||
const toCreateKeyspace: boolean = this.keyspaceCreateNew();
|
||||
const useAutoPilotForKeyspace: boolean =
|
||||
(!this.hasAutoPilotV2FeatureFlag() && this.isSharedAutoPilotSelected() && !!this.sharedAutoPilotThroughput()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && this.isSharedAutoPilotSelected() && !!this.selectedSharedAutoPilotTier());
|
||||
const createKeyspaceQueryPrefix: string = `CREATE KEYSPACE ${this.keyspaceId().trim()} WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 3 }`;
|
||||
const createKeyspaceQuery: string = this.keyspaceHasSharedOffer()
|
||||
? useAutoPilotForKeyspace
|
||||
? !this.hasAutoPilotV2FeatureFlag()
|
||||
? `${createKeyspaceQueryPrefix} AND ${autoPilotCommand}=${this.sharedAutoPilotThroughput()};`
|
||||
: `${createKeyspaceQueryPrefix} AND ${autoPilotCommand}=${this.selectedSharedAutoPilotTier()};`
|
||||
: `${createKeyspaceQueryPrefix} AND cosmosdb_provisioned_throughput=${this.keyspaceThroughput()};`
|
||||
: `${createKeyspaceQueryPrefix};`;
|
||||
const createTableQueryPrefix: string = `${this.createTableQuery()}${this.tableId().trim()} ${this.userTableQuery()}`;
|
||||
let createTableQuery: string;
|
||||
|
||||
if (this.dedicateTableThroughput() || !this.keyspaceHasSharedOffer()) {
|
||||
if (this.isAutoPilotSelected() && this.selectedAutoPilotThroughput()) {
|
||||
createTableQuery = `${createTableQueryPrefix} WITH ${autoPilotCommand}=${this.selectedAutoPilotThroughput()};`;
|
||||
} else {
|
||||
createTableQuery = `${createTableQueryPrefix} WITH cosmosdb_provisioned_throughput=${this.throughput()};`;
|
||||
}
|
||||
} else {
|
||||
createTableQuery = `${createTableQueryPrefix};`;
|
||||
}
|
||||
|
||||
const addCollectionPaneStartMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collection: ko.toJS({
|
||||
id: this.tableId(),
|
||||
storage: Constants.BackendDefaults.multiPartitionStorageInGb,
|
||||
offerThroughput: this.throughput(),
|
||||
partitionKey: "",
|
||||
databaseId: this.keyspaceId(),
|
||||
rupm: false,
|
||||
hasDedicatedThroughput: this.dedicateTableThroughput()
|
||||
}),
|
||||
keyspaceHasSharedOffer: this.keyspaceHasSharedOffer(),
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
storage: "u",
|
||||
throughput: this.throughput(),
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
toCreateKeyspace: toCreateKeyspace,
|
||||
createKeyspaceQuery: createKeyspaceQuery,
|
||||
createTableQuery: createTableQuery
|
||||
};
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateCollection, addCollectionPaneStartMessage);
|
||||
if (toCreateKeyspace) {
|
||||
createTableAndKeyspacePromise = (<CassandraAPIDataClient>this.container.tableDataClient).createTableAndKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
this.container,
|
||||
createTableQuery,
|
||||
createKeyspaceQuery
|
||||
);
|
||||
} else {
|
||||
createTableAndKeyspacePromise = (<CassandraAPIDataClient>this.container.tableDataClient).createTableAndKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
this.container,
|
||||
createTableQuery
|
||||
);
|
||||
}
|
||||
createTableAndKeyspacePromise.then(
|
||||
() => {
|
||||
this.container.refreshAllDatabases();
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
const addCollectionPaneSuccessMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collection: ko.toJS({
|
||||
id: this.tableId(),
|
||||
storage: Constants.BackendDefaults.multiPartitionStorageInGb,
|
||||
offerThroughput: this.throughput(),
|
||||
partitionKey: "",
|
||||
databaseId: this.keyspaceId(),
|
||||
rupm: false,
|
||||
hasDedicatedThroughput: this.dedicateTableThroughput()
|
||||
}),
|
||||
keyspaceHasSharedOffer: this.keyspaceHasSharedOffer(),
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
storage: "u",
|
||||
throughput: this.throughput(),
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
toCreateKeyspace: toCreateKeyspace,
|
||||
createKeyspaceQuery: createKeyspaceQuery,
|
||||
createTableQuery: createTableQuery
|
||||
};
|
||||
TelemetryProcessor.traceSuccess(Action.CreateCollection, addCollectionPaneSuccessMessage, startKey);
|
||||
},
|
||||
reason => {
|
||||
this.formErrors(reason.exceptionMessage);
|
||||
this.isExecuting(false);
|
||||
const addCollectionPaneFailedMessage = {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collection: {
|
||||
id: this.tableId(),
|
||||
storage: Constants.BackendDefaults.multiPartitionStorageInGb,
|
||||
offerThroughput: this.throughput(),
|
||||
partitionKey: "",
|
||||
databaseId: this.keyspaceId(),
|
||||
rupm: false,
|
||||
hasDedicatedThroughput: this.dedicateTableThroughput()
|
||||
},
|
||||
keyspaceHasSharedOffer: this.keyspaceHasSharedOffer(),
|
||||
subscriptionType: ViewModels.SubscriptionType[this.container.subscriptionType()],
|
||||
subscriptionQuotaId: this.container.quotaId(),
|
||||
defaultsCheck: {
|
||||
storage: "u",
|
||||
throughput: this.throughput(),
|
||||
flight: this.container.flight()
|
||||
},
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
toCreateKeyspace: toCreateKeyspace,
|
||||
createKeyspaceQuery: createKeyspaceQuery,
|
||||
createTableQuery: createTableQuery,
|
||||
error: reason
|
||||
};
|
||||
TelemetryProcessor.traceFailure(Action.CreateCollection, addCollectionPaneFailedMessage, startKey);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
super.resetData();
|
||||
const throughputDefaults = AddCollectionUtility.Utilities.getDefaultThroughput(
|
||||
this.container.flight(),
|
||||
this.container.subscriptionType()
|
||||
);
|
||||
this.isAutoPilotSelected(false);
|
||||
this.isSharedAutoPilotSelected(false);
|
||||
this.selectedAutoPilotTier(null);
|
||||
this.selectedSharedAutoPilotTier(null);
|
||||
this.selectedAutoPilotThroughput(AutoPilotUtils.minAutoPilotThroughput);
|
||||
this.sharedAutoPilotThroughput(AutoPilotUtils.minAutoPilotThroughput);
|
||||
this.throughput(throughputDefaults.unlimited(this.container));
|
||||
this.keyspaceThroughput(throughputDefaults.shared);
|
||||
this.maxThroughputRU(throughputDefaults.unlimitedmax);
|
||||
this.minThroughputRU(throughputDefaults.unlimitedmin);
|
||||
this.createTableQuery("CREATE TABLE ");
|
||||
this.userTableQuery("(userid int, name text, email text, PRIMARY KEY (userid))");
|
||||
this.tableId("");
|
||||
this.keyspaceId("");
|
||||
this.throughputSpendAck(false);
|
||||
this.keyspaceHasSharedOffer(false);
|
||||
this.keyspaceCreateNew(true);
|
||||
}
|
||||
|
||||
private _isValid(): boolean {
|
||||
const throughput = this.throughput();
|
||||
const keyspaceThroughput = this.keyspaceThroughput();
|
||||
|
||||
const sharedAutoscaleThroughput = this.sharedAutoPilotThroughput() * 1;
|
||||
if (
|
||||
!this.hasAutoPilotV2FeatureFlag() &&
|
||||
this.isSharedAutoPilotSelected() &&
|
||||
sharedAutoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K &&
|
||||
!this.sharedThroughputSpendAck()
|
||||
) {
|
||||
this.formErrors(`Please acknowledge the estimated monthly spend.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const dedicatedAutoscaleThroughput = this.selectedAutoPilotThroughput() * 1;
|
||||
if (
|
||||
!this.hasAutoPilotV2FeatureFlag() &&
|
||||
this.isAutoPilotSelected() &&
|
||||
dedicatedAutoscaleThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K &&
|
||||
!this.throughputSpendAck()
|
||||
) {
|
||||
this.formErrors(`Please acknowledge the estimated monthly spend.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(this.keyspaceCreateNew() && this.keyspaceHasSharedOffer() && this.isSharedAutoPilotSelected()) ||
|
||||
this.isAutoPilotSelected()
|
||||
) {
|
||||
const autoPilot = this._getAutoPilot();
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() &&
|
||||
(!autoPilot ||
|
||||
!autoPilot.maxThroughput ||
|
||||
!AutoPilotUtils.isValidAutoPilotThroughput(autoPilot.maxThroughput))) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() &&
|
||||
(!autoPilot || !autoPilot.autopilotTier || !AutoPilotUtils.isValidAutoPilotTier(autoPilot.autopilotTier)))
|
||||
) {
|
||||
this.formErrors(
|
||||
!this.hasAutoPilotV2FeatureFlag()
|
||||
? `Please enter a value greater than ${AutoPilotUtils.minAutoPilotThroughput} for autopilot throughput`
|
||||
: "Please select an Autopilot tier from the list."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (throughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K && !this.throughputSpendAck()) {
|
||||
this.formErrors(`Please acknowledge the estimated daily spend.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.keyspaceHasSharedOffer() &&
|
||||
this.keyspaceCreateNew() &&
|
||||
keyspaceThroughput > SharedConstants.CollectionCreation.DefaultCollectionRUs100K &&
|
||||
!this.sharedThroughputSpendAck()
|
||||
) {
|
||||
this.formErrors("Please acknowledge the estimated daily spend");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _getAutoPilot(): DataModels.AutoPilotCreationSettings {
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() &&
|
||||
this.keyspaceCreateNew() &&
|
||||
this.keyspaceHasSharedOffer() &&
|
||||
this.isSharedAutoPilotSelected() &&
|
||||
this.sharedAutoPilotThroughput()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() &&
|
||||
this.keyspaceCreateNew() &&
|
||||
this.keyspaceHasSharedOffer() &&
|
||||
this.isSharedAutoPilotSelected() &&
|
||||
this.selectedSharedAutoPilotTier())
|
||||
) {
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? {
|
||||
maxThroughput: this.sharedAutoPilotThroughput() * 1
|
||||
}
|
||||
: { autopilotTier: this.selectedSharedAutoPilotTier() };
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.hasAutoPilotV2FeatureFlag() && this.selectedAutoPilotThroughput()) ||
|
||||
(this.hasAutoPilotV2FeatureFlag() && this.selectedAutoPilotTier())
|
||||
) {
|
||||
return !this.hasAutoPilotV2FeatureFlag()
|
||||
? {
|
||||
maxThroughput: this.selectedAutoPilotThroughput() * 1
|
||||
}
|
||||
: { autopilotTier: this.selectedAutoPilotTier() };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
59
src/Explorer/Panes/ClusterLibraryPane.html
Normal file
59
src/Explorer/Panes/ClusterLibraryPane.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="clusterLibraryPane">
|
||||
<!-- Cluster Library -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Cluster Library header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cluster Library header - End -->
|
||||
|
||||
<!-- Cluster Library errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Cluster Library errors - End -->
|
||||
|
||||
<!-- Cluster Library inputs - Start -->
|
||||
<div class="paneMainContent"><div data-bind="react: clusterLibraryGridAdapter"></div></div>
|
||||
<!-- Cluster Library inputs - End -->
|
||||
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Save" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Cluster Library - End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
236
src/Explorer/Panes/ClusterLibraryPane.ts
Normal file
236
src/Explorer/Panes/ClusterLibraryPane.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ClusterLibraryGridAdapter } from "../Controls/LibraryManagement/ClusterLibraryGridAdapter";
|
||||
import { ClusterLibraryGridProps, ClusterLibraryItem } from "../Controls/LibraryManagement/ClusterLibraryGrid";
|
||||
import { Library, SparkCluster, SparkClusterLibrary } from "../../Contracts/DataModels";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
export class ClusterLibraryPane extends ContextualPaneBase {
|
||||
public clusterLibraryGridAdapter: ClusterLibraryGridAdapter;
|
||||
|
||||
private _clusterLibraryProps: ko.Observable<ClusterLibraryGridProps>;
|
||||
private _originalCluster: SparkCluster;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Cluster Libraries");
|
||||
|
||||
this._clusterLibraryProps = ko.observable<ClusterLibraryGridProps>({
|
||||
libraryItems: [],
|
||||
onInstalledChanged: this._onInstalledChanged
|
||||
});
|
||||
this.clusterLibraryGridAdapter = new ClusterLibraryGridAdapter();
|
||||
this.clusterLibraryGridAdapter.parameters = this._clusterLibraryProps;
|
||||
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
const resourceId: string = this.container.databaseAccount() && this.container.databaseAccount().id;
|
||||
Promise.all([this._getLibraries(resourceId), this._getDefaultCluster(resourceId)]).then(
|
||||
result => {
|
||||
const [libraries, cluster] = result;
|
||||
this._originalCluster = cluster;
|
||||
const libraryItems = this._mapClusterLibraries(cluster, libraries);
|
||||
this._updateClusterLibraryGridStates({ libraryItems });
|
||||
},
|
||||
reason => {
|
||||
const parsedError = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(parsedError[0].message);
|
||||
}
|
||||
);
|
||||
super.open();
|
||||
}
|
||||
|
||||
public submit(): void {
|
||||
const resourceId: string = this.container.databaseAccount() && this.container.databaseAccount().id;
|
||||
this.isExecuting(true);
|
||||
if (this._areLibrariesChanged()) {
|
||||
const newLibraries = this._clusterLibraryProps()
|
||||
.libraryItems.filter(lib => lib.installed)
|
||||
.map(lib => ({ name: lib.name }));
|
||||
this._updateClusterLibraries(resourceId, this._originalCluster, newLibraries).then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
},
|
||||
reason => {
|
||||
this.isExecuting(false);
|
||||
const parsedError = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(parsedError[0].message);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateClusterLibraryGridStates(states: Partial<ClusterLibraryGridProps>): void {
|
||||
const merged = { ...this._clusterLibraryProps(), ...states };
|
||||
this._clusterLibraryProps(merged);
|
||||
this._clusterLibraryProps.valueHasMutated();
|
||||
}
|
||||
|
||||
private _onInstalledChanged = (libraryName: string, installed: boolean): void => {
|
||||
const items = this._clusterLibraryProps().libraryItems;
|
||||
const library = items.find(item => item.name === libraryName);
|
||||
library.installed = installed;
|
||||
this._clusterLibraryProps.valueHasMutated();
|
||||
};
|
||||
|
||||
private _areLibrariesChanged(): boolean {
|
||||
const original = this._originalCluster.properties && this._originalCluster.properties.libraries;
|
||||
const changed = this._clusterLibraryProps()
|
||||
.libraryItems.filter(lib => lib.installed)
|
||||
.map(lib => lib.name);
|
||||
if (original.length !== changed.length) {
|
||||
return true;
|
||||
}
|
||||
const newLibraries = new Set(changed);
|
||||
for (let o of original) {
|
||||
if (!newLibraries.has(o.name)) {
|
||||
return false;
|
||||
}
|
||||
newLibraries.delete(o.name);
|
||||
}
|
||||
return newLibraries.size === 0;
|
||||
}
|
||||
|
||||
private _mapClusterLibraries(cluster: SparkCluster, libraries: Library[]): ClusterLibraryItem[] {
|
||||
const clusterLibraries = cluster && cluster.properties && cluster.properties.libraries;
|
||||
const libraryItems = libraries.map(lib => ({
|
||||
...lib,
|
||||
installed: clusterLibraries.some(clusterLib => clusterLib.name === lib.name)
|
||||
}));
|
||||
return libraryItems;
|
||||
}
|
||||
|
||||
private async _getLibraries(resourceId: string): Promise<Library[]> {
|
||||
if (!resourceId) {
|
||||
return Promise.reject("invalid inputs");
|
||||
}
|
||||
|
||||
if (!this.container.sparkClusterManager) {
|
||||
return Promise.reject("cluster client is not initialized yet");
|
||||
}
|
||||
|
||||
const inProgressId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Fetching libraries...`
|
||||
);
|
||||
try {
|
||||
return await this.container.sparkClusterManager.getLibrariesAsync(resourceId);
|
||||
} catch (e) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to fetch libraries. Reason: ${JSON.stringify(e)}`
|
||||
);
|
||||
Logger.logError(e, "Explorer/_getLibraries");
|
||||
throw e;
|
||||
} finally {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressId);
|
||||
}
|
||||
}
|
||||
|
||||
private async _getDefaultCluster(resourceId: string, clusterId: string = "default"): Promise<SparkCluster> {
|
||||
if (!resourceId) {
|
||||
return Promise.reject("invalid inputs");
|
||||
}
|
||||
|
||||
if (!this.container.sparkClusterManager) {
|
||||
return Promise.reject("cluster client is not initialized yet");
|
||||
}
|
||||
|
||||
const inProgressId = NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.InProgress, `Fetching cluster...`);
|
||||
try {
|
||||
const cluster = await this.container.sparkClusterManager.getClusterAsync(resourceId, clusterId);
|
||||
return cluster;
|
||||
} catch (e) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to fetch cluster. Reason: ${JSON.stringify(e)}`
|
||||
);
|
||||
Logger.logError(e, "Explorer/_getCluster");
|
||||
throw e;
|
||||
} finally {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressId);
|
||||
}
|
||||
}
|
||||
|
||||
private async _updateClusterLibraries(
|
||||
resourceId: string,
|
||||
originalCluster: SparkCluster,
|
||||
newLibrarys: SparkClusterLibrary[]
|
||||
): Promise<void> {
|
||||
if (!originalCluster || !resourceId) {
|
||||
return Promise.reject("Invalid inputs");
|
||||
}
|
||||
|
||||
if (!this.container.sparkClusterManager) {
|
||||
return Promise.reject("Cluster client is not initialized yet");
|
||||
}
|
||||
|
||||
TelemetryProcessor.traceStart(Action.ClusterLibraryManage, {
|
||||
resourceId,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
area: "ClusterLibraryPane/_updateClusterLibraries",
|
||||
originalCluster,
|
||||
newLibrarys
|
||||
});
|
||||
|
||||
let newCluster = originalCluster;
|
||||
newCluster.properties.libraries = newLibrarys;
|
||||
|
||||
const consoleId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Updating ${newCluster.name} libraries...`
|
||||
);
|
||||
|
||||
try {
|
||||
const cluster = await this.container.sparkClusterManager.updateClusterAsync(
|
||||
resourceId,
|
||||
originalCluster.name,
|
||||
newCluster
|
||||
);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Successfully updated ${newCluster.name} libraries.`
|
||||
);
|
||||
TelemetryProcessor.traceSuccess(Action.ClusterLibraryManage, {
|
||||
resourceId,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
area: "ClusterLibraryPane/_updateClusterLibraries",
|
||||
cluster
|
||||
});
|
||||
} catch (e) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to upload ${newCluster.name} libraries. Reason: ${JSON.stringify(e)}`
|
||||
);
|
||||
TelemetryProcessor.traceFailure(Action.ClusterLibraryManage, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
area: "ClusterLibraryPane/_updateClusterLibraries",
|
||||
error: e
|
||||
});
|
||||
Logger.logError(e, "Explorer/_updateClusterLibraries");
|
||||
throw e;
|
||||
} finally {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(consoleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/Explorer/Panes/ContextualPaneBase.ts
Normal file
129
src/Explorer/Panes/ContextualPaneBase.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import * as ko from "knockout";
|
||||
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { KeyCodes } from "../../Common/Constants";
|
||||
import { WaitsForTemplateViewModel } from "../WaitsForTemplateViewModel";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import DocumentClientUtilityBase from "../../Common/DocumentClientUtilityBase";
|
||||
|
||||
// TODO: Use specific actions for logging telemetry data
|
||||
export abstract class ContextualPaneBase extends WaitsForTemplateViewModel implements ViewModels.ContextualPane {
|
||||
public id: string;
|
||||
public container: ViewModels.Explorer;
|
||||
public firstFieldHasFocus: ko.Observable<boolean>;
|
||||
public formErrorsDetails: ko.Observable<string>;
|
||||
public formErrors: ko.Observable<string>;
|
||||
public title: ko.Observable<string>;
|
||||
public visible: ko.Observable<boolean>;
|
||||
public documentClientUtility: DocumentClientUtilityBase;
|
||||
public isExecuting: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super();
|
||||
this.id = options.id;
|
||||
this.container = options.container;
|
||||
this.documentClientUtility = options.documentClientUtility;
|
||||
this.visible = options.visible || ko.observable(false);
|
||||
this.firstFieldHasFocus = ko.observable<boolean>(false);
|
||||
this.formErrors = ko.observable<string>();
|
||||
this.title = ko.observable<string>();
|
||||
this.formErrorsDetails = ko.observable<string>();
|
||||
this.isExecuting = ko.observable<boolean>(false);
|
||||
this.container.isNotificationConsoleExpanded.subscribe((isExpanded: boolean) => {
|
||||
this.resizePane();
|
||||
});
|
||||
this.container.isNotificationConsoleExpanded.extend({ rateLimit: 10 });
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.close();
|
||||
this.container.isAccountReady() &&
|
||||
TelemetryProcessor.trace(Action.ContextualPane, ActionModifiers.Close, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.visible(false);
|
||||
this.isExecuting(false);
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.visible(true);
|
||||
this.firstFieldHasFocus(true);
|
||||
this.resizePane();
|
||||
this.container.isAccountReady() &&
|
||||
TelemetryProcessor.trace(Action.ContextualPane, ActionModifiers.Open, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
this.firstFieldHasFocus(false);
|
||||
this.formErrors(null);
|
||||
this.formErrorsDetails(null);
|
||||
}
|
||||
|
||||
public showErrorDetails() {
|
||||
this.container.expandConsole();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
this.container.isAccountReady() &&
|
||||
TelemetryProcessor.trace(Action.ContextualPane, ActionModifiers.Submit, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
}
|
||||
|
||||
public onCloseKeyPress(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public onPaneKeyDown(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === KeyCodes.Escape) {
|
||||
this.close();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public onSubmitKeyPress(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.submit();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resizePane(): void {
|
||||
const paneElement: HTMLElement = document.getElementById(this.id);
|
||||
const notificationConsoleElement: HTMLElement = document.getElementById("explorerNotificationConsole");
|
||||
const newPaneElementHeight = window.innerHeight - $(notificationConsoleElement).height();
|
||||
|
||||
$(paneElement).height(newPaneElementHeight);
|
||||
}
|
||||
}
|
||||
109
src/Explorer/Panes/DeleteCollectionConfirmationPane.html
Normal file
109
src/Explorer/Panes/DeleteCollectionConfirmationPane.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="deletecollectionconfirmationpane">
|
||||
<!-- Delete Collection Confirmation form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Delete Collection Confirmation header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Collection Confirmation header - End -->
|
||||
|
||||
<div class="warningErrorContainer" data-bind="visible: !formErrors()">
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneWarningIcon" src="/warning.svg" alt="Warning"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
Warning! The action you are about to take cannot be undone. Continuing will permanently delete this
|
||||
resource and all of its children resources.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Collection Confirmation errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="
|
||||
visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a class="errorLink" role="link" data-bind="click: showErrorDetails">More details</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Collection Confirmation errors - End -->
|
||||
|
||||
<!-- Delete Collection Confirmation inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<span class="mandatoryStar">*</span> <span data-bind="text: collectionIdConfirmationText"></span>
|
||||
<p>
|
||||
<input
|
||||
type="text"
|
||||
data-test="confirmCollectionId"
|
||||
name="collectionIdConfirmation"
|
||||
required
|
||||
class="collid"
|
||||
data-bind="value: collectionIdConfirmation, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Confirm by typing the collection id"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div data-bind="visible: recordDeleteFeedback">
|
||||
<div>Help us improve Azure Cosmos DB!</div>
|
||||
<div>What is the reason why you are deleting this container?</div>
|
||||
<p>
|
||||
<textarea
|
||||
type="text"
|
||||
data-test="containerDeleteFeedback"
|
||||
name="containerDeleteFeedback"
|
||||
rows="3"
|
||||
cols="53"
|
||||
class="collid"
|
||||
maxlength="512"
|
||||
data-bind="value: containerDeleteFeedback"
|
||||
aria-label="Help us improve Azure Cosmos DB! What is the reason why you are deleting this container?"
|
||||
>
|
||||
</textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input type="submit" data-test="deleteCollection" value="OK" class="btncreatecoll1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Collection Confirmation inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Delete Collection Confirmation form - End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
158
src/Explorer/Panes/DeleteCollectionConfirmationPane.test.ts
Normal file
158
src/Explorer/Panes/DeleteCollectionConfirmationPane.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as ko from "knockout";
|
||||
import * as sinon from "sinon";
|
||||
import Q from "q";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import DeleteCollectionConfirmationPane from "./DeleteCollectionConfirmationPane";
|
||||
import DeleteFeedback from "../../Common/DeleteFeedback";
|
||||
import DocumentClientUtilityBase from "../../Common/DocumentClientUtilityBase";
|
||||
import Explorer from "../Explorer";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { CollectionStub, DatabaseStub, ExplorerStub } from "../OpenActionsStubs";
|
||||
import { TreeNode } from "../../Contracts/ViewModels";
|
||||
|
||||
describe("Delete Collection Confirmation Pane", () => {
|
||||
describe("Explorer.isLastCollection()", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
});
|
||||
|
||||
it("should be true if 1 database and 1 collection", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
database.collections = ko.observableArray<ViewModels.Collection>([new CollectionStub({})]);
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
|
||||
expect(explorer.isLastCollection()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if if 1 database and 2 collection", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
database.collections = ko.observableArray<ViewModels.Collection>([
|
||||
new CollectionStub({}),
|
||||
new CollectionStub({})
|
||||
]);
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
|
||||
expect(explorer.isLastCollection()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false if 2 database and 1 collection each", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
database.collections = ko.observableArray<ViewModels.Collection>([new CollectionStub({})]);
|
||||
let database2: ViewModels.Database = new DatabaseStub({});
|
||||
database2.collections = ko.observableArray<ViewModels.Collection>([new CollectionStub({})]);
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database, database2]);
|
||||
expect(explorer.isLastCollection()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false if 0 databases", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>();
|
||||
database.collections = ko.observableArray<ViewModels.Collection>();
|
||||
expect(explorer.isLastCollection()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRecordFeedback()", () => {
|
||||
it("should return true if last collection and database does not have shared throughput else false", () => {
|
||||
let fakeDocumentClientUtility = sinon.createStubInstance<DocumentClientUtilityBase>(
|
||||
DocumentClientUtilityBase as any
|
||||
);
|
||||
let fakeExplorer = sinon.createStubInstance<ExplorerStub>(ExplorerStub as any);
|
||||
sinon.stub(fakeExplorer, "isNotificationConsoleExpanded").value(ko.observable<boolean>(false));
|
||||
|
||||
let pane = new DeleteCollectionConfirmationPane({
|
||||
documentClientUtility: fakeDocumentClientUtility as any,
|
||||
id: "deletecollectionconfirmationpane",
|
||||
visible: ko.observable<boolean>(false),
|
||||
container: fakeExplorer as any
|
||||
});
|
||||
|
||||
fakeExplorer.isLastCollection.returns(true);
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(false);
|
||||
pane.container = fakeExplorer as any;
|
||||
expect(pane.shouldRecordFeedback()).toBe(true);
|
||||
|
||||
fakeExplorer.isLastCollection.returns(true);
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(true);
|
||||
pane.container = fakeExplorer as any;
|
||||
expect(pane.shouldRecordFeedback()).toBe(false);
|
||||
|
||||
fakeExplorer.isLastCollection.returns(false);
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(false);
|
||||
pane.container = fakeExplorer as any;
|
||||
expect(pane.shouldRecordFeedback()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit()", () => {
|
||||
let telemetryProcessorSpy: sinon.SinonSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
telemetryProcessorSpy = sinon.spy(TelemetryProcessor, "trace");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
telemetryProcessorSpy.restore();
|
||||
});
|
||||
|
||||
it("it should log feedback if last collection and database is not shared", () => {
|
||||
let selectedCollectionId = "testCol";
|
||||
let fakeDocumentClientUtility = sinon.createStubInstance<DocumentClientUtilityBase>(
|
||||
DocumentClientUtilityBase as any
|
||||
);
|
||||
fakeDocumentClientUtility.deleteCollection.returns(Q.resolve(null));
|
||||
let fakeExplorer = sinon.createStubInstance<ExplorerStub>(ExplorerStub as any);
|
||||
fakeExplorer.findSelectedCollection.returns(
|
||||
new CollectionStub({
|
||||
id: ko.observable<string>(selectedCollectionId),
|
||||
rid: "test"
|
||||
})
|
||||
);
|
||||
sinon.stub(fakeExplorer, "isNotificationConsoleExpanded").value(ko.observable<boolean>(false));
|
||||
sinon.stub(fakeExplorer, "selectedCollectionId").value(ko.observable<string>(selectedCollectionId));
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(false);
|
||||
const SubscriptionId = "testId";
|
||||
const AccountName = "testAccount";
|
||||
sinon.stub(fakeExplorer, "databaseAccount").value(
|
||||
ko.observable<ViewModels.DatabaseAccount>({
|
||||
id: SubscriptionId,
|
||||
name: AccountName
|
||||
} as ViewModels.DatabaseAccount)
|
||||
);
|
||||
sinon.stub(fakeExplorer, "defaultExperience").value(ko.observable<string>("DocumentDB"));
|
||||
sinon.stub(fakeExplorer, "isPreferredApiCassandra").value(
|
||||
ko.computed(() => {
|
||||
return false;
|
||||
})
|
||||
);
|
||||
sinon.stub(fakeExplorer, "documentClientUtility").value(fakeDocumentClientUtility);
|
||||
sinon.stub(fakeExplorer, "selectedNode").value(ko.observable<TreeNode>());
|
||||
fakeExplorer.isLastCollection.returns(true);
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(false);
|
||||
|
||||
let pane = new DeleteCollectionConfirmationPane({
|
||||
documentClientUtility: fakeDocumentClientUtility as any,
|
||||
id: "deletecollectionconfirmationpane",
|
||||
visible: ko.observable<boolean>(false),
|
||||
container: fakeExplorer as any
|
||||
});
|
||||
pane.collectionIdConfirmation = ko.observable<string>(selectedCollectionId);
|
||||
const Feedback = "my feedback";
|
||||
pane.containerDeleteFeedback(Feedback);
|
||||
|
||||
return pane.submit().then(() => {
|
||||
expect(telemetryProcessorSpy.called).toBe(true);
|
||||
let deleteFeedback = new DeleteFeedback(SubscriptionId, AccountName, DataModels.ApiKind.SQL, Feedback);
|
||||
expect(
|
||||
telemetryProcessorSpy.calledWith(
|
||||
Action.DeleteCollection,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
143
src/Explorer/Panes/DeleteCollectionConfirmationPane.ts
Normal file
143
src/Explorer/Panes/DeleteCollectionConfirmationPane.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
import DeleteFeedback from "../../Common/DeleteFeedback";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export default class DeleteCollectionConfirmationPane extends ContextualPaneBase
|
||||
implements ViewModels.DeleteCollectionConfirmationPane {
|
||||
public collectionIdConfirmationText: ko.Observable<string>;
|
||||
public collectionIdConfirmation: ko.Observable<string>;
|
||||
public containerDeleteFeedback: ko.Observable<string>;
|
||||
public recordDeleteFeedback: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.collectionIdConfirmationText = ko.observable<string>("Confirm by typing the collection id");
|
||||
this.collectionIdConfirmation = ko.observable<string>();
|
||||
this.containerDeleteFeedback = ko.observable<string>();
|
||||
this.recordDeleteFeedback = ko.observable<boolean>(false);
|
||||
this.title("Delete Collection");
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public submit(): Q.Promise<any> {
|
||||
if (!this._isValid()) {
|
||||
const selectedCollection: ViewModels.Collection = this.container.findSelectedCollection();
|
||||
this.formErrors("Input collection name does not match the selected collection");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Error while deleting collection ${selectedCollection && selectedCollection.id()}: ${this.formErrors()}`
|
||||
);
|
||||
return Q.resolve();
|
||||
}
|
||||
|
||||
this.formErrors("");
|
||||
this.isExecuting(true);
|
||||
const selectedCollection = <ViewModels.Collection>this.container.findSelectedCollection();
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteCollection, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collectionId: selectedCollection.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
let promise: Q.Promise<any>;
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
promise = (<CassandraAPIDataClient>this.container.tableDataClient).deleteTableOrKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
`DROP TABLE ${selectedCollection.databaseId}.${selectedCollection.id()};`,
|
||||
this.container
|
||||
);
|
||||
} else {
|
||||
promise = this.container.documentClientUtility.deleteCollection(selectedCollection);
|
||||
}
|
||||
return promise.then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
this.container.selectedNode(selectedCollection.database);
|
||||
this.container.closeAllTabsForResource(selectedCollection.rid);
|
||||
this.container.refreshAllDatabases();
|
||||
this.resetData();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteCollection,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collectionId: selectedCollection.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
if (this.shouldRecordFeedback()) {
|
||||
let deleteFeedback = new DeleteFeedback(
|
||||
this.container.databaseAccount().id,
|
||||
this.container.databaseAccount().name,
|
||||
DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()),
|
||||
this.containerDeleteFeedback()
|
||||
);
|
||||
|
||||
TelemetryProcessor.trace(
|
||||
Action.DeleteCollection,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
);
|
||||
|
||||
this.containerDeleteFeedback("");
|
||||
}
|
||||
},
|
||||
(reason: any) => {
|
||||
this.isExecuting(false);
|
||||
const message = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(message[0].message);
|
||||
this.formErrorsDetails(message[0].message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteCollection,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
collectionId: selectedCollection.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
this.collectionIdConfirmation("");
|
||||
super.resetData();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.recordDeleteFeedback(this.shouldRecordFeedback());
|
||||
super.open();
|
||||
}
|
||||
|
||||
public shouldRecordFeedback(): boolean {
|
||||
return this.container.isLastCollection() && !this.container.isSelectedDatabaseShared();
|
||||
}
|
||||
|
||||
private _isValid(): boolean {
|
||||
const selectedCollection: ViewModels.Collection = this.container.findSelectedCollection();
|
||||
|
||||
if (!selectedCollection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.collectionIdConfirmation() === selectedCollection.id();
|
||||
}
|
||||
}
|
||||
109
src/Explorer/Panes/DeleteDatabaseConfirmationPane.html
Normal file
109
src/Explorer/Panes/DeleteDatabaseConfirmationPane.html
Normal file
@@ -0,0 +1,109 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="deletedatabaseconfirmationpane">
|
||||
<!-- Delete Databaes Confirmation form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Delete Database Confirmation header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Database Confirmation header - End -->
|
||||
|
||||
<div class="warningErrorContainer" data-bind="visible: !formErrors()">
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneWarningIcon" src="/warning.svg" alt="Warning"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
Warning! The action you are about to take cannot be undone. Continuing will permanently delete this
|
||||
resource and all of its children resources.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Database Confirmation errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="
|
||||
visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a class="errorLink" role="link" data-bind="click: showErrorDetails">More details</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Database Confirmation errors - End -->
|
||||
|
||||
<!-- Delete Database Confirmation inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<span class="mandatoryStar">*</span> <span data-bind="text: databaseIdConfirmationText"></span>
|
||||
<p>
|
||||
<input
|
||||
type="text"
|
||||
name="databaseIdConfirmation"
|
||||
data-test="confirmDatabaseId"
|
||||
required
|
||||
class="collid"
|
||||
data-bind="value: databaseIdConfirmation, hasFocus: firstFieldHasFocus"
|
||||
aria-label="Confirm by typing the database id"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<div data-bind="visible: recordDeleteFeedback">
|
||||
<div>Help us improve Azure Cosmos DB!</div>
|
||||
<div>What is the reason why you are deleting this database?</div>
|
||||
<p>
|
||||
<textarea
|
||||
type="text"
|
||||
data-test="databaseDeleteFeedback"
|
||||
name="databaseDeleteFeedback"
|
||||
rows="3"
|
||||
cols="53"
|
||||
maxlength="512"
|
||||
class="collid"
|
||||
data-bind="value: databaseDeleteFeedback"
|
||||
aria-label="Help us improve Azure Cosmos DB! What is the reason why you are deleting this database?"
|
||||
>
|
||||
</textarea>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input type="submit" data-test="deleteDatabase" value="OK" class="btncreatecoll1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete Database Confirmation inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Delete Database Confirmation form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
151
src/Explorer/Panes/DeleteDatabaseConfirmationPane.test.ts
Normal file
151
src/Explorer/Panes/DeleteDatabaseConfirmationPane.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import * as ko from "knockout";
|
||||
import * as sinon from "sinon";
|
||||
import Q from "q";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import DeleteDatabaseConfirmationPane from "./DeleteDatabaseConfirmationPane";
|
||||
import DeleteFeedback from "../../Common/DeleteFeedback";
|
||||
import DocumentClientUtilityBase from "../../Common/DocumentClientUtilityBase";
|
||||
import Explorer from "../Explorer";
|
||||
import { CollectionStub, DatabaseStub, ExplorerStub } from "../OpenActionsStubs";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { TreeNode } from "../../Contracts/ViewModels";
|
||||
|
||||
describe("Delete Database Confirmation Pane", () => {
|
||||
describe("Explorer.isLastDatabase() and Explorer.isLastNonEmptyDatabase()", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
});
|
||||
|
||||
it("should be true if only 1 database", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
|
||||
expect(explorer.isLastDatabase()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if only 2 databases", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
let database2: ViewModels.Database = new DatabaseStub({});
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database, database2]);
|
||||
expect(explorer.isLastDatabase()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false if not last empty database", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
|
||||
expect(explorer.isLastNonEmptyDatabase()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true if last non empty database", () => {
|
||||
let database: ViewModels.Database = new DatabaseStub({});
|
||||
database.collections = ko.observableArray<ViewModels.Collection>([new CollectionStub({})]);
|
||||
explorer.databases = ko.observableArray<ViewModels.Database>([database]);
|
||||
expect(explorer.isLastNonEmptyDatabase()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRecordFeedback()", () => {
|
||||
it("should return true if last non empty database or is last database that has shared throughput, else false", () => {
|
||||
let fakeDocumentClientUtility = sinon.createStubInstance<DocumentClientUtilityBase>(
|
||||
DocumentClientUtilityBase as any
|
||||
);
|
||||
let fakeExplorer = sinon.createStubInstance<ExplorerStub>(ExplorerStub as any);
|
||||
sinon.stub(fakeExplorer, "isNotificationConsoleExpanded").value(ko.observable<boolean>(false));
|
||||
|
||||
let pane = new DeleteDatabaseConfirmationPane({
|
||||
documentClientUtility: fakeDocumentClientUtility as any,
|
||||
id: "deletedatabaseconfirmationpane",
|
||||
visible: ko.observable<boolean>(false),
|
||||
container: fakeExplorer as any
|
||||
});
|
||||
|
||||
fakeExplorer.isLastNonEmptyDatabase.returns(true);
|
||||
pane.container = fakeExplorer as any;
|
||||
expect(pane.shouldRecordFeedback()).toBe(true);
|
||||
|
||||
fakeExplorer.isLastDatabase.returns(true);
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(true);
|
||||
pane.container = fakeExplorer as any;
|
||||
expect(pane.shouldRecordFeedback()).toBe(true);
|
||||
|
||||
fakeExplorer.isLastNonEmptyDatabase.returns(false);
|
||||
fakeExplorer.isLastDatabase.returns(true);
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(false);
|
||||
pane.container = fakeExplorer as any;
|
||||
expect(pane.shouldRecordFeedback()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submit()", () => {
|
||||
let telemetryProcessorSpy: sinon.SinonSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
telemetryProcessorSpy = sinon.spy(TelemetryProcessor, "trace");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
telemetryProcessorSpy.restore();
|
||||
});
|
||||
|
||||
it("on submit() it should log feedback if last non empty database or is last database that has shared throughput", () => {
|
||||
let selectedDatabaseId = "testDB";
|
||||
let fakeDocumentClientUtility = sinon.createStubInstance<DocumentClientUtilityBase>(
|
||||
DocumentClientUtilityBase as any
|
||||
);
|
||||
fakeDocumentClientUtility.deleteDatabase.returns(Q.resolve(null));
|
||||
let fakeExplorer = sinon.createStubInstance<ExplorerStub>(ExplorerStub as any);
|
||||
fakeExplorer.findSelectedDatabase.returns(
|
||||
new DatabaseStub({
|
||||
id: ko.observable<string>(selectedDatabaseId),
|
||||
rid: "test",
|
||||
collections: ko.observableArray<ViewModels.Collection>()
|
||||
})
|
||||
);
|
||||
sinon.stub(fakeExplorer, "isNotificationConsoleExpanded").value(ko.observable<boolean>(false));
|
||||
sinon.stub(fakeExplorer, "selectedDatabaseId").value(ko.observable<string>(selectedDatabaseId));
|
||||
fakeExplorer.isSelectedDatabaseShared.returns(false);
|
||||
const SubscriptionId = "testId";
|
||||
const AccountName = "testAccount";
|
||||
sinon.stub(fakeExplorer, "databaseAccount").value(
|
||||
ko.observable<ViewModels.DatabaseAccount>({
|
||||
id: SubscriptionId,
|
||||
name: AccountName
|
||||
} as ViewModels.DatabaseAccount)
|
||||
);
|
||||
sinon.stub(fakeExplorer, "defaultExperience").value(ko.observable<string>("DocumentDB"));
|
||||
sinon.stub(fakeExplorer, "isPreferredApiCassandra").value(
|
||||
ko.computed(() => {
|
||||
return false;
|
||||
})
|
||||
);
|
||||
sinon.stub(fakeExplorer, "documentClientUtility").value(fakeDocumentClientUtility);
|
||||
sinon.stub(fakeExplorer, "selectedNode").value(ko.observable<TreeNode>());
|
||||
fakeExplorer.isLastNonEmptyDatabase.returns(true);
|
||||
|
||||
let pane = new DeleteDatabaseConfirmationPane({
|
||||
documentClientUtility: fakeDocumentClientUtility as any,
|
||||
id: "deletedatabaseconfirmationpane",
|
||||
visible: ko.observable<boolean>(false),
|
||||
container: fakeExplorer as any
|
||||
});
|
||||
pane.databaseIdConfirmation = ko.observable<string>(selectedDatabaseId);
|
||||
const Feedback = "my feedback";
|
||||
pane.databaseDeleteFeedback(Feedback);
|
||||
|
||||
return pane.submit().then(() => {
|
||||
expect(telemetryProcessorSpy.called).toBe(true);
|
||||
let deleteFeedback = new DeleteFeedback(SubscriptionId, AccountName, DataModels.ApiKind.SQL, Feedback);
|
||||
expect(
|
||||
telemetryProcessorSpy.calledWith(
|
||||
Action.DeleteDatabase,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
150
src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts
Normal file
150
src/Explorer/Panes/DeleteDatabaseConfirmationPane.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import { CassandraAPIDataClient } from "../Tables/TableDataClient";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
import DeleteFeedback from "../../Common/DeleteFeedback";
|
||||
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export default class DeleteDatabaseConfirmationPane extends ContextualPaneBase
|
||||
implements ViewModels.DeleteDatabaseConfirmationPane {
|
||||
public databaseIdConfirmationText: ko.Observable<string>;
|
||||
public databaseIdConfirmation: ko.Observable<string>;
|
||||
public databaseDeleteFeedback: ko.Observable<string>;
|
||||
public recordDeleteFeedback: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.databaseIdConfirmationText = ko.observable<string>("Confirm by typing the database id");
|
||||
this.databaseIdConfirmation = ko.observable<string>();
|
||||
this.databaseDeleteFeedback = ko.observable<string>();
|
||||
this.recordDeleteFeedback = ko.observable<boolean>(false);
|
||||
this.title("Delete Database");
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public submit(): Q.Promise<any> {
|
||||
if (!this._isValid()) {
|
||||
const selectedDatabase: ViewModels.Database = this.container.findSelectedDatabase();
|
||||
this.formErrors("Input database name does not match the selected database");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Error while deleting collection ${selectedDatabase && selectedDatabase.id()}: ${this.formErrors()}`
|
||||
);
|
||||
return Q.resolve();
|
||||
}
|
||||
|
||||
this.formErrors("");
|
||||
this.isExecuting(true);
|
||||
const selectedDatabase = this.container.findSelectedDatabase();
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.DeleteDatabase, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
let promise: Q.Promise<any>;
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
promise = (<CassandraAPIDataClient>this.container.tableDataClient).deleteTableOrKeyspace(
|
||||
this.container.databaseAccount().properties.cassandraEndpoint,
|
||||
this.container.databaseAccount().id,
|
||||
`DROP KEYSPACE ${selectedDatabase.id()};`,
|
||||
this.container
|
||||
);
|
||||
} else {
|
||||
promise = this.container.documentClientUtility.deleteDatabase(selectedDatabase);
|
||||
}
|
||||
return promise.then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this.close();
|
||||
this.container.refreshAllDatabases();
|
||||
this.container.closeAllTabsForResource(selectedDatabase.rid);
|
||||
this.container.selectedNode(null);
|
||||
selectedDatabase
|
||||
.collections()
|
||||
.forEach((collection: ViewModels.Collection) => this.container.closeAllTabsForResource(collection.rid));
|
||||
this.resetData();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.DeleteDatabase,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
|
||||
if (this.shouldRecordFeedback()) {
|
||||
let deleteFeedback = new DeleteFeedback(
|
||||
this.container.databaseAccount().id,
|
||||
this.container.databaseAccount().name,
|
||||
DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience()),
|
||||
this.databaseDeleteFeedback()
|
||||
);
|
||||
|
||||
TelemetryProcessor.trace(
|
||||
Action.DeleteDatabase,
|
||||
ActionModifiers.Mark,
|
||||
JSON.stringify(deleteFeedback, Object.getOwnPropertyNames(deleteFeedback))
|
||||
);
|
||||
|
||||
this.databaseDeleteFeedback("");
|
||||
}
|
||||
},
|
||||
(reason: any) => {
|
||||
this.isExecuting(false);
|
||||
const message = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(message[0].message);
|
||||
this.formErrorsDetails(message[0].message);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.DeleteDatabase,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
databaseId: selectedDatabase.id(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
this.databaseIdConfirmation("");
|
||||
super.resetData();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.recordDeleteFeedback(this.shouldRecordFeedback());
|
||||
super.open();
|
||||
}
|
||||
|
||||
public shouldRecordFeedback(): boolean {
|
||||
return (
|
||||
this.container.isLastNonEmptyDatabase() ||
|
||||
(this.container.isLastDatabase() && this.container.isSelectedDatabaseShared())
|
||||
);
|
||||
}
|
||||
|
||||
private _isValid(): boolean {
|
||||
const selectedDatabase = this.container.findSelectedDatabase();
|
||||
if (!selectedDatabase) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.databaseIdConfirmation() === selectedDatabase.id();
|
||||
}
|
||||
}
|
||||
165
src/Explorer/Panes/ExecuteSprocParamsPane.html
Normal file
165
src/Explorer/Panes/ExecuteSprocParamsPane.html
Normal file
@@ -0,0 +1,165 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="executesprocparamspane">
|
||||
<!-- Input params form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: execute">
|
||||
<!-- Input params header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Input params header - End -->
|
||||
|
||||
<!-- Input params errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="
|
||||
visible: formErrorsDetails() && formErrorsDetails() !== '',
|
||||
click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Input params errors - End -->
|
||||
|
||||
<!-- Script for each param clause to be used for executing a stored procedure -->
|
||||
<script type="text/html" id="param-template">
|
||||
<tr>
|
||||
<td class="paramTemplateRow">
|
||||
<select class="dataTypeSelector" data-bind="value: type, attr: { 'aria-label': type }">
|
||||
<option value="custom">Custom</option>
|
||||
<option value="string">String</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="paramTemplateRow">
|
||||
<input class="valueTextBox" aria-label="Param" data-bind="textInput: value" />
|
||||
<span class="spEntityAddCancel" data-bind="click: $parent.deleteParam.bind($parent, $index()), event: { keypress: $parent.onDeleteParamKeyPress.bind($parent, $index()) }" role="button" tabindex="0">
|
||||
<img src="/Entity_cancel.svg" alt="Delete param">
|
||||
</span>
|
||||
<span class="spEntityAddCancel" data-bind="click: $parent.addNewParamAtIndex.bind($parent, $index()), event: { keypress: $parent.onAddNewParamAtIndexKeyPress.bind($parent, $index()) }" role="button" tabindex="0">
|
||||
<img src="/Add-property.svg" alt="Add param">
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</script>
|
||||
|
||||
<!-- Input params input - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<!-- Partition key input - Start -->
|
||||
<div class="partitionKeyContainer" data-bind="visible: collectionHasPartitionKey">
|
||||
<div class="inputHeader">Partition key value</div>
|
||||
<div class="scrollBox">
|
||||
<table class="paramsClauseTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="paramTemplateRow">
|
||||
<select
|
||||
class="dataTypeSelector"
|
||||
data-bind="value: partitionKeyType, attr: { 'aria-label': partitionKeyType }"
|
||||
>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="string">String</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="paramTemplateRow">
|
||||
<input
|
||||
class="partitionKeyValue"
|
||||
id="partitionKeyValue"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
aria-label="Partition key value"
|
||||
data-bind="textInput: partitionKeyValue"
|
||||
autofocus
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Partition key input - End -->
|
||||
|
||||
<!-- Input params table - Start -->
|
||||
<div class="paramsTable">
|
||||
<div class="enterInputParams">Enter input parameters (if any)</div>
|
||||
<div class="scrollBox" id="executeSprocParamsScroll">
|
||||
<table class="paramsClauseTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="paramTableTypeHead">Type</th>
|
||||
<th>Param</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-bind="template: { name: 'param-template', foreach: params }"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
id="addNewParamLink"
|
||||
class="addNewParam"
|
||||
data-bind="click: addNewParam, event: { keypress: onAddNewParamKeyPress }, attr:{ title: addNewParamLabel }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<span>
|
||||
<img src="/Add-property.svg" alt="Add new param" />
|
||||
<span class="addNewParamLabel" data-bind="text: addNewParamLabel" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Input params table - End -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input
|
||||
type="submit"
|
||||
value="Execute"
|
||||
class="btncreatecoll1"
|
||||
data-bind="{ css: { btnDisabled: !executeButtonEnabled() }}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Input param input - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Input params form - End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
172
src/Explorer/Panes/ExecuteSprocParamsPane.ts
Normal file
172
src/Explorer/Panes/ExecuteSprocParamsPane.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import * as ko from "knockout";
|
||||
import * as _ from "underscore";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
|
||||
export interface ExecuteSprocParam {
|
||||
type: ko.Observable<string>;
|
||||
value: ko.Observable<string>;
|
||||
}
|
||||
|
||||
type UnwrappedExecuteSprocParam = {
|
||||
type: string;
|
||||
value: any;
|
||||
};
|
||||
|
||||
export class ExecuteSprocParamsPane extends ContextualPaneBase implements ViewModels.ExecuteSprocParamsPane {
|
||||
public params: ko.ObservableArray<ExecuteSprocParam>;
|
||||
public partitionKeyType: ko.Observable<string>;
|
||||
public partitionKeyValue: ko.Observable<string>;
|
||||
public collectionHasPartitionKey: ko.Observable<boolean>;
|
||||
public addNewParamLabel: string = "Add New Param";
|
||||
public executeButtonEnabled: ko.Computed<boolean>;
|
||||
|
||||
private _selectedSproc: ViewModels.StoredProcedure;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Input parameters");
|
||||
this.partitionKeyType = ko.observable<string>("custom");
|
||||
this.partitionKeyValue = ko.observable<string>();
|
||||
this.executeButtonEnabled = ko.computed<boolean>(() => this.validPartitionKeyValue());
|
||||
this.params = ko.observableArray<ExecuteSprocParam>([
|
||||
{ type: ko.observable<string>("string"), value: ko.observable<string>() }
|
||||
]);
|
||||
this.collectionHasPartitionKey = ko.observable<boolean>();
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public open() {
|
||||
super.open();
|
||||
const currentSelectedSproc: ViewModels.StoredProcedure =
|
||||
this.container && this.container.findSelectedStoredProcedure();
|
||||
if (!!currentSelectedSproc && !!this._selectedSproc && this._selectedSproc.rid !== currentSelectedSproc.rid) {
|
||||
this.params([]);
|
||||
this.partitionKeyValue("");
|
||||
}
|
||||
this._selectedSproc = currentSelectedSproc;
|
||||
this.collectionHasPartitionKey((this.container && !!this.container.findSelectedCollection().partitionKey) || false);
|
||||
const focusElement = document.getElementById("partitionKeyValue");
|
||||
focusElement && focusElement.focus();
|
||||
}
|
||||
|
||||
public execute = () => {
|
||||
this.formErrors("");
|
||||
const partitionKeyValue: string = (() => {
|
||||
if (!this.collectionHasPartitionKey()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type: string = this.partitionKeyType();
|
||||
let value: string = this.partitionKeyValue();
|
||||
|
||||
if (type === "custom") {
|
||||
if (value === "undefined" || value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (value === "null" || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch (e) {
|
||||
this.formErrors(`Invalid param specified: ${value}`);
|
||||
this.formErrorsDetails(`Invalid param specified: ${value} is not a valid literal value`);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
})();
|
||||
const unwrappedParams: UnwrappedExecuteSprocParam[] = ko.toJS(this.params());
|
||||
const wrappedSprocParams: UnwrappedExecuteSprocParam[] = !this.params()
|
||||
? undefined
|
||||
: _.map(unwrappedParams, (unwrappedParam: UnwrappedExecuteSprocParam) => {
|
||||
let paramValue: string = unwrappedParam.value;
|
||||
|
||||
if (unwrappedParam.type === "custom" && (paramValue === "undefined" || paramValue === "")) {
|
||||
paramValue = undefined;
|
||||
} else if (unwrappedParam.type === "custom") {
|
||||
try {
|
||||
paramValue = JSON.parse(paramValue);
|
||||
} catch (e) {
|
||||
this.formErrors(`Invalid param specified: ${paramValue}`);
|
||||
this.formErrorsDetails(`Invalid param specified: ${paramValue} is not a valid literal value`);
|
||||
}
|
||||
}
|
||||
|
||||
unwrappedParam.value = paramValue;
|
||||
return unwrappedParam;
|
||||
});
|
||||
|
||||
if (this.formErrors()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sprocParams = wrappedSprocParams && _.pluck(wrappedSprocParams, "value");
|
||||
this._selectedSproc.execute(sprocParams, partitionKeyValue);
|
||||
this.close();
|
||||
};
|
||||
|
||||
private validPartitionKeyValue = (): boolean => {
|
||||
return !this.collectionHasPartitionKey || (this.partitionKeyValue() != null && this.partitionKeyValue().length > 0);
|
||||
};
|
||||
|
||||
public addNewParam = (): void => {
|
||||
this.params.push({ type: ko.observable<string>("string"), value: ko.observable<string>() });
|
||||
this._maintainFocusOnAddNewParamLink();
|
||||
};
|
||||
|
||||
public onAddNewParamKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.addNewParam();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public addNewParamAtIndex = (index: number): void => {
|
||||
this.params.splice(index, 0, { type: ko.observable<string>("string"), value: ko.observable<string>() });
|
||||
};
|
||||
|
||||
public onAddNewParamAtIndexKeyPress = (index: number, source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.addNewParamAtIndex(index);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public deleteParam = (indexToRemove: number): void => {
|
||||
const params = _.reject(this.params(), (param: ExecuteSprocParam, index: number) => {
|
||||
return index === indexToRemove;
|
||||
});
|
||||
this.params(params);
|
||||
};
|
||||
|
||||
public onDeleteParamKeyPress = (indexToRemove: number, source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Space || event.keyCode === Constants.KeyCodes.Enter) {
|
||||
this.deleteParam(indexToRemove);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public close(): void {
|
||||
super.close();
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
}
|
||||
|
||||
private _maintainFocusOnAddNewParamLink(): void {
|
||||
const addNewParamLink = document.getElementById("addNewParamLink");
|
||||
addNewParamLink.scrollIntoView();
|
||||
}
|
||||
}
|
||||
14
src/Explorer/Panes/GitHubReposPane.html
Normal file
14
src/Explorer/Panes/GitHubReposPane.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="gitHubReposPane">
|
||||
<div class="contextual-pane-in">
|
||||
<div class="paneContentContainer" data-bind="react: gitHubReposAdapter" />
|
||||
</div>
|
||||
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" alt="loading indicator image" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
349
src/Explorer/Panes/GitHubReposPane.ts
Normal file
349
src/Explorer/Panes/GitHubReposPane.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Areas, HttpStatusCodes } from "../../Common/Constants";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { GitHubClient, IGitHubRepo } from "../../GitHub/GitHubClient";
|
||||
import { IPinnedRepo, JunoClient } from "../../Juno/JunoClient";
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { GitHubUtils } from "../../Utils/GitHubUtils";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { AuthorizeAccessComponent } from "../Controls/GitHub/AuthorizeAccessComponent";
|
||||
import { GitHubReposComponentProps, RepoListItem, GitHubReposComponent } from "../Controls/GitHub/GitHubReposComponent";
|
||||
import { GitHubReposComponentAdapter } from "../Controls/GitHub/GitHubReposComponentAdapter";
|
||||
import { BranchesProps, PinnedReposProps, UnpinnedReposProps } from "../Controls/GitHub/ReposListComponent";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
|
||||
export class GitHubReposPane extends ContextualPaneBase {
|
||||
private static readonly PageSize = 30;
|
||||
|
||||
private gitHubClient: GitHubClient;
|
||||
private junoClient: JunoClient;
|
||||
|
||||
private branchesProps: Record<string, BranchesProps>;
|
||||
private pinnedReposProps: PinnedReposProps;
|
||||
private unpinnedReposProps: UnpinnedReposProps;
|
||||
|
||||
private gitHubReposProps: GitHubReposComponentProps;
|
||||
private gitHubReposAdapter: GitHubReposComponentAdapter;
|
||||
|
||||
private allGitHubRepos: IGitHubRepo[];
|
||||
private pinnedReposUpdated: boolean;
|
||||
|
||||
constructor(options: ViewModels.GitHubReposPaneOptions) {
|
||||
super(options);
|
||||
|
||||
this.gitHubClient = options.gitHubClient;
|
||||
this.junoClient = options.junoClient;
|
||||
|
||||
this.branchesProps = {};
|
||||
this.pinnedReposProps = {
|
||||
repos: []
|
||||
};
|
||||
this.unpinnedReposProps = {
|
||||
repos: [],
|
||||
hasMore: true,
|
||||
isLoading: true,
|
||||
loadMore: (): Promise<void> => this.loadMoreUnpinnedRepos()
|
||||
};
|
||||
|
||||
this.gitHubReposProps = {
|
||||
showAuthorizeAccess: true,
|
||||
authorizeAccessProps: {
|
||||
scope: this.getOAuthScope(),
|
||||
authorizeAccess: (scope): void => this.connectToGitHub(scope)
|
||||
},
|
||||
reposListProps: {
|
||||
branchesProps: this.branchesProps,
|
||||
pinnedReposProps: this.pinnedReposProps,
|
||||
unpinnedReposProps: this.unpinnedReposProps,
|
||||
pinRepo: (item): Promise<void> => this.pinRepo(item),
|
||||
unpinRepo: (item): Promise<void> => this.unpinRepo(item)
|
||||
},
|
||||
addRepoProps: {
|
||||
container: this.container,
|
||||
getRepo: (owner, repo): Promise<IGitHubRepo> => this.getRepo(owner, repo),
|
||||
pinRepo: (item): Promise<void> => this.pinRepo(item)
|
||||
},
|
||||
resetConnection: (): void => this.setup(true),
|
||||
onOkClick: (): Promise<void> => this.submit(),
|
||||
onCancelClick: (): void => this.cancel()
|
||||
};
|
||||
this.gitHubReposAdapter = new GitHubReposComponentAdapter(this.gitHubReposProps);
|
||||
|
||||
this.allGitHubRepos = [];
|
||||
this.pinnedReposUpdated = false;
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
this.resetData();
|
||||
this.setup();
|
||||
|
||||
super.open();
|
||||
}
|
||||
|
||||
public async submit(): Promise<void> {
|
||||
const pinnedReposUpdated = this.pinnedReposUpdated;
|
||||
const reposToPin: IPinnedRepo[] = this.pinnedReposProps.repos.map(repo => GitHubUtils.toPinnedRepo(repo));
|
||||
|
||||
// Submit resets data too
|
||||
super.submit();
|
||||
|
||||
if (pinnedReposUpdated) {
|
||||
try {
|
||||
const response = await this.junoClient.updatePinnedRepos(reposToPin);
|
||||
if (response.status !== HttpStatusCodes.OK) {
|
||||
throw new Error(`Received HTTP ${response.status} when saving pinned repos`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `Failed to save pinned repos: ${error}`;
|
||||
Logger.logError(message, "GitHubReposPane/submit");
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public resetData(): void {
|
||||
// Reset cached branches
|
||||
this.branchesProps = {};
|
||||
this.gitHubReposProps.reposListProps.branchesProps = this.branchesProps;
|
||||
|
||||
// Reset cached pinned and unpinned repos
|
||||
this.pinnedReposProps.repos = [];
|
||||
this.unpinnedReposProps.repos = [];
|
||||
|
||||
// Reset cached repos
|
||||
this.allGitHubRepos = [];
|
||||
|
||||
// Reset flags
|
||||
this.pinnedReposUpdated = false;
|
||||
this.unpinnedReposProps.hasMore = true;
|
||||
this.unpinnedReposProps.isLoading = true;
|
||||
|
||||
this.triggerRender();
|
||||
|
||||
super.resetData();
|
||||
}
|
||||
|
||||
private getOAuthScope(): string {
|
||||
return (
|
||||
this.container.gitHubOAuthService?.getTokenObservable()()?.scope || AuthorizeAccessComponent.Scopes.Public.key
|
||||
);
|
||||
}
|
||||
|
||||
private setup(forceShowConnectToGitHub = false): void {
|
||||
forceShowConnectToGitHub || !this.container.gitHubOAuthService.isLoggedIn()
|
||||
? this.setupForConnectToGitHub()
|
||||
: this.setupForManageRepos();
|
||||
}
|
||||
|
||||
private setupForConnectToGitHub(): void {
|
||||
this.gitHubReposProps.showAuthorizeAccess = true;
|
||||
this.gitHubReposProps.authorizeAccessProps.scope = this.getOAuthScope();
|
||||
this.isExecuting(false);
|
||||
this.title(GitHubReposComponent.ConnectToGitHubTitle); // Used for telemetry
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
private async setupForManageRepos(): Promise<void> {
|
||||
this.gitHubReposProps.showAuthorizeAccess = false;
|
||||
this.isExecuting(false);
|
||||
this.title(GitHubReposComponent.ManageGitHubRepoTitle); // Used for telemetry
|
||||
TelemetryProcessor.trace(Action.NotebooksGitHubManageRepo, ActionModifiers.Mark, {
|
||||
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.Notebook
|
||||
});
|
||||
this.triggerRender();
|
||||
|
||||
this.refreshManageReposComponent();
|
||||
}
|
||||
|
||||
private calculateUnpinnedRepos(): RepoListItem[] {
|
||||
const unpinnedGitHubRepos = this.allGitHubRepos.filter(
|
||||
gitHubRepo =>
|
||||
this.pinnedReposProps.repos.findIndex(
|
||||
pinnedRepo => pinnedRepo.key === GitHubUtils.toRepoFullName(gitHubRepo.owner.login, gitHubRepo.name)
|
||||
) === -1
|
||||
);
|
||||
return unpinnedGitHubRepos.map(gitHubRepo => ({
|
||||
key: GitHubUtils.toRepoFullName(gitHubRepo.owner.login, gitHubRepo.name),
|
||||
repo: gitHubRepo,
|
||||
branches: []
|
||||
}));
|
||||
}
|
||||
|
||||
private async loadMoreBranches(repo: IGitHubRepo): Promise<void> {
|
||||
const branchesProps = this.branchesProps[GitHubUtils.toRepoFullName(repo.owner.login, repo.name)];
|
||||
branchesProps.hasMore = true;
|
||||
branchesProps.isLoading = true;
|
||||
this.triggerRender();
|
||||
|
||||
const nextPage = Math.floor(branchesProps.branches.length / GitHubReposPane.PageSize) + 1;
|
||||
try {
|
||||
const response = await this.gitHubClient.getBranchesAsync(
|
||||
repo.owner.login,
|
||||
repo.name,
|
||||
nextPage,
|
||||
GitHubReposPane.PageSize
|
||||
);
|
||||
if (response.status !== HttpStatusCodes.OK) {
|
||||
throw new Error(`Received HTTP ${response.status} when fetching branches`);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
branchesProps.branches = branchesProps.branches.concat(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `Failed to fetch branches: ${error}`;
|
||||
Logger.logError(message, "GitHubReposPane/loadMoreBranches");
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
|
||||
}
|
||||
|
||||
branchesProps.isLoading = false;
|
||||
branchesProps.hasMore = branchesProps.branches.length === GitHubReposPane.PageSize * nextPage;
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
private async loadMoreUnpinnedRepos(): Promise<void> {
|
||||
this.unpinnedReposProps.isLoading = true;
|
||||
this.unpinnedReposProps.hasMore = true;
|
||||
this.triggerRender();
|
||||
|
||||
const nextPage = Math.floor(this.allGitHubRepos.length / GitHubReposPane.PageSize) + 1;
|
||||
try {
|
||||
const response = await this.gitHubClient.getReposAsync(nextPage, GitHubReposPane.PageSize);
|
||||
if (response.status !== HttpStatusCodes.OK) {
|
||||
throw new Error(`Received HTTP ${response.status} when fetching unpinned repos`);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
this.allGitHubRepos = this.allGitHubRepos.concat(response.data);
|
||||
this.unpinnedReposProps.repos = this.calculateUnpinnedRepos();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `Failed to fetch unpinned repos: ${error}`;
|
||||
Logger.logError(message, "GitHubReposPane/loadMoreUnpinnedRepos");
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
|
||||
}
|
||||
|
||||
this.unpinnedReposProps.isLoading = false;
|
||||
this.unpinnedReposProps.hasMore = this.allGitHubRepos.length === GitHubReposPane.PageSize * nextPage;
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
private async getRepo(owner: string, repo: string): Promise<IGitHubRepo> {
|
||||
try {
|
||||
const response = await this.gitHubClient.getRepoAsync(owner, repo);
|
||||
if (response.status !== HttpStatusCodes.OK) {
|
||||
throw new Error(`Received HTTP ${response.status} when fetching repo`);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = `Failed to fetch repo: ${error}`;
|
||||
Logger.logError(message, "GitHubReposPane/getRepo");
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async pinRepo(item: RepoListItem): Promise<void> {
|
||||
this.pinnedReposUpdated = true;
|
||||
const initialReposLength = this.pinnedReposProps.repos.length;
|
||||
|
||||
const existingRepo = this.pinnedReposProps.repos.find(repo => repo.key === item.key);
|
||||
if (existingRepo) {
|
||||
existingRepo.branches = item.branches;
|
||||
} else {
|
||||
this.pinnedReposProps.repos = [...this.pinnedReposProps.repos, item];
|
||||
}
|
||||
|
||||
this.unpinnedReposProps.repos = this.calculateUnpinnedRepos();
|
||||
this.triggerRender();
|
||||
|
||||
if (this.pinnedReposProps.repos.length > initialReposLength) {
|
||||
this.refreshBranchesForPinnedRepos();
|
||||
}
|
||||
}
|
||||
|
||||
private async unpinRepo(item: RepoListItem): Promise<void> {
|
||||
this.pinnedReposUpdated = true;
|
||||
this.pinnedReposProps.repos = this.pinnedReposProps.repos.filter(pinnedRepo => pinnedRepo.key !== item.key);
|
||||
this.unpinnedReposProps.repos = this.calculateUnpinnedRepos();
|
||||
this.triggerRender();
|
||||
}
|
||||
|
||||
private async refreshManageReposComponent(): Promise<void> {
|
||||
await this.refreshPinnedRepoListItems();
|
||||
this.refreshBranchesForPinnedRepos();
|
||||
this.refreshUnpinnedRepoListItems();
|
||||
}
|
||||
|
||||
private async refreshPinnedRepoListItems(): Promise<void> {
|
||||
this.pinnedReposProps.repos = [];
|
||||
this.triggerRender();
|
||||
|
||||
try {
|
||||
const response = await this.junoClient.getPinnedRepos(
|
||||
this.container.gitHubOAuthService?.getTokenObservable()()?.scope
|
||||
);
|
||||
if (response.status !== HttpStatusCodes.OK && response.status !== HttpStatusCodes.NoContent) {
|
||||
throw new Error(`Received HTTP ${response.status} when fetching pinned repos`);
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
const pinnedRepos = response.data.map(
|
||||
pinnedRepo =>
|
||||
({
|
||||
key: GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name),
|
||||
branches: pinnedRepo.branches,
|
||||
repo: GitHubUtils.toGitHubRepo(pinnedRepo)
|
||||
} as RepoListItem)
|
||||
);
|
||||
|
||||
this.pinnedReposProps.repos = pinnedRepos;
|
||||
this.triggerRender();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `Failed to fetch pinned repos: ${error}`;
|
||||
Logger.logError(message, "GitHubReposPane/refreshPinnedReposListItems");
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, message);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshBranchesForPinnedRepos(): void {
|
||||
this.pinnedReposProps.repos.map(item => {
|
||||
if (!this.branchesProps[item.key]) {
|
||||
this.branchesProps[item.key] = {
|
||||
branches: [],
|
||||
hasMore: true,
|
||||
isLoading: true,
|
||||
loadMore: (): Promise<void> => this.loadMoreBranches(item.repo)
|
||||
};
|
||||
this.loadMoreBranches(item.repo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshUnpinnedRepoListItems(): Promise<void> {
|
||||
this.allGitHubRepos = [];
|
||||
this.unpinnedReposProps.repos = [];
|
||||
this.loadMoreUnpinnedRepos();
|
||||
}
|
||||
|
||||
private connectToGitHub(scope: string): void {
|
||||
this.isExecuting(true);
|
||||
TelemetryProcessor.trace(Action.NotebooksGitHubAuthorize, ActionModifiers.Mark, {
|
||||
databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.Notebook,
|
||||
scopesSelected: scope
|
||||
});
|
||||
this.container.gitHubOAuthService.startOAuth(scope);
|
||||
}
|
||||
|
||||
private triggerRender(): void {
|
||||
this.gitHubReposAdapter.triggerRender();
|
||||
}
|
||||
}
|
||||
60
src/Explorer/Panes/GraphNewVertexPane.html
Normal file
60
src/Explorer/Panes/GraphNewVertexPane.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" data-bind="attr: { id: id }">
|
||||
<!-- New Vertex form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- New Vertex header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span>New Vertex</span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
tabindex="0"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- New Vertex header - End -->
|
||||
|
||||
<!-- New Vertex errors - Start -->
|
||||
<div
|
||||
aria-live="assertive"
|
||||
class="warningErrorContainer"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '' , click: showErrorDetails, event: { keypress: onMoreDetailsKeyPress }"
|
||||
tabindex="0"
|
||||
>
|
||||
More details
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- New Vertex errors - End -->
|
||||
|
||||
<!-- New Vertex inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<new-vertex-form
|
||||
class="newvertexContainer"
|
||||
params="{ newVertexData: tempVertexData, firstFieldHasFocus: firstFieldHasFocus, partitionKeyProperty: partitionKeyProperty }"
|
||||
></new-vertex-form>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="OK" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- New Vertex inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- New Vertex form - End -->
|
||||
</div>
|
||||
</div>
|
||||
10
src/Explorer/Panes/GraphNewVertexPane.less
Normal file
10
src/Explorer/Panes/GraphNewVertexPane.less
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "../../../less/Common/Constants";
|
||||
|
||||
.newvertexContainer {
|
||||
height:100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
white-space: nowrap;
|
||||
.flex-display();
|
||||
.flex-direction();
|
||||
}
|
||||
59
src/Explorer/Panes/GraphStylingPane.html
Normal file
59
src/Explorer/Panes/GraphStylingPane.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" data-bind="attr: { id: id }">
|
||||
<!-- Graph Styling form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Graph Styling header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span>Graph Styling</span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Graph Styling header - End -->
|
||||
|
||||
<!-- Graph Styling errors - Start -->
|
||||
<div
|
||||
aria-live="assertive"
|
||||
class="warningErrorContainer"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '' , click: showErrorDetails"
|
||||
>
|
||||
More details
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Graph Styling errors - End -->
|
||||
|
||||
<!-- Add graph configuration - Start -->
|
||||
<div class="paneMainContent">
|
||||
<graph-style
|
||||
id="graphStyleComponent"
|
||||
params="{ config:graphConfigUIData, firstFieldHasFocus: firstFieldHasFocus }"
|
||||
></graph-style>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="OK" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- Add Graph configuration - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Graph Styling form - End -->
|
||||
</div>
|
||||
</div>
|
||||
68
src/Explorer/Panes/GraphStylingPane.ts
Normal file
68
src/Explorer/Panes/GraphStylingPane.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
|
||||
export default class GraphStylingPane extends ContextualPaneBase implements ViewModels.GraphStylingPane {
|
||||
public graphConfigUIData: ViewModels.GraphConfigUiData;
|
||||
private remoteConfig: ViewModels.GraphConfigUiData;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
|
||||
this.graphConfigUIData = {
|
||||
showNeighborType: ko.observable(ViewModels.NeighborType.TARGETS_ONLY),
|
||||
nodeProperties: ko.observableArray([]),
|
||||
nodePropertiesWithNone: ko.observableArray([]),
|
||||
nodeCaptionChoice: ko.observable(null),
|
||||
nodeColorKeyChoice: ko.observable(null),
|
||||
nodeIconChoice: ko.observable(null),
|
||||
nodeIconSet: ko.observable(null)
|
||||
};
|
||||
|
||||
this.graphConfigUIData.nodeCaptionChoice.subscribe(val => {
|
||||
if (this.remoteConfig) {
|
||||
this.remoteConfig.nodeCaptionChoice(val);
|
||||
}
|
||||
});
|
||||
this.graphConfigUIData.nodeColorKeyChoice.subscribe(val => {
|
||||
if (this.remoteConfig) {
|
||||
this.remoteConfig.nodeColorKeyChoice(val);
|
||||
}
|
||||
});
|
||||
this.graphConfigUIData.nodeIconChoice.subscribe(val => {
|
||||
if (this.remoteConfig) {
|
||||
this.remoteConfig.nodeIconChoice(val);
|
||||
}
|
||||
});
|
||||
this.graphConfigUIData.nodeIconSet.subscribe(val => {
|
||||
if (this.remoteConfig) {
|
||||
this.remoteConfig.nodeIconSet(val);
|
||||
}
|
||||
});
|
||||
this.graphConfigUIData.showNeighborType.subscribe(val => {
|
||||
if (this.remoteConfig) {
|
||||
this.remoteConfig.showNeighborType(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setData(config: ViewModels.GraphConfigUiData): void {
|
||||
// Update pane ko's with config's ko
|
||||
this.graphConfigUIData.nodeIconChoice(config.nodeIconChoice());
|
||||
this.graphConfigUIData.nodeIconSet(config.nodeIconSet());
|
||||
this.graphConfigUIData.nodeProperties(config.nodeProperties());
|
||||
this.graphConfigUIData.nodePropertiesWithNone(config.nodePropertiesWithNone());
|
||||
this.graphConfigUIData.showNeighborType(config.showNeighborType());
|
||||
// Make sure these two happen *after* setting the options of the dropdown,
|
||||
// otherwise, the ko will not get set if the choice is not part of the options
|
||||
this.graphConfigUIData.nodeCaptionChoice(config.nodeCaptionChoice());
|
||||
this.graphConfigUIData.nodeColorKeyChoice(config.nodeColorKeyChoice());
|
||||
|
||||
this.remoteConfig = config;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.remoteConfig = null;
|
||||
super.close();
|
||||
}
|
||||
}
|
||||
55
src/Explorer/Panes/LibraryManagePane.html
Normal file
55
src/Explorer/Panes/LibraryManagePane.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="libraryManagePane">
|
||||
<!-- Library Manage -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Library Manage header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Library Manage header - End -->
|
||||
|
||||
<!-- Library Manage errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Library Manage errors - End -->
|
||||
|
||||
<!-- Library Manage inputs - Start -->
|
||||
<div class="paneMainContent"><div data-bind="react: libraryManageComponentAdapter"></div></div>
|
||||
<!-- Library Manage inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Library Manage - End -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
372
src/Explorer/Panes/LibraryManagePane.ts
Normal file
372
src/Explorer/Panes/LibraryManagePane.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { LibraryManageComponentAdapter } from "../Controls/LibraryManagement/LibraryManageComponentAdapter";
|
||||
import {
|
||||
LibraryManageComponentProps,
|
||||
LibraryAddNameTextFieldProps,
|
||||
LibraryAddUrlTextFieldProps,
|
||||
LibraryAddButtonProps,
|
||||
LibraryManageGridProps
|
||||
} from "../Controls/LibraryManagement/LibraryManage";
|
||||
import { Library } from "../../Contracts/DataModels";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
export class LibraryManagePane extends ContextualPaneBase {
|
||||
public libraryManageComponentAdapter: LibraryManageComponentAdapter;
|
||||
|
||||
private _libraryManageProps: ko.Observable<LibraryManageComponentProps>;
|
||||
private _libraryManageStates: { isNameValid: boolean; isUrlValid: boolean };
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Libraries");
|
||||
|
||||
this._libraryManageStates = {
|
||||
isNameValid: true,
|
||||
isUrlValid: true
|
||||
};
|
||||
this._libraryManageProps = ko.observable<LibraryManageComponentProps>({
|
||||
addProps: {
|
||||
nameProps: {
|
||||
libraryName: "",
|
||||
onLibraryNameChange: this._onLibraryNameChange,
|
||||
onLibraryNameValidated: this._onLibraryNameValidated
|
||||
},
|
||||
urlProps: {
|
||||
libraryAddress: "",
|
||||
onLibraryAddressChange: this._onLibraryAddressChange,
|
||||
onLibraryAddressValidated: this._onLibraryAddressValidated
|
||||
},
|
||||
buttonProps: {
|
||||
disabled: false,
|
||||
onLibraryAddClick: this._onLibraryAddClick
|
||||
}
|
||||
},
|
||||
gridProps: {
|
||||
items: [],
|
||||
onLibraryDeleteClick: this._onLibraryDeleteClick
|
||||
}
|
||||
});
|
||||
this.libraryManageComponentAdapter = new LibraryManageComponentAdapter();
|
||||
this.libraryManageComponentAdapter.parameters = this._libraryManageProps;
|
||||
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public open(): void {
|
||||
const resourceId: string = this.container.databaseAccount() && this.container.databaseAccount().id;
|
||||
this._getLibraries(resourceId).then(
|
||||
(libraries: Library[]) => {
|
||||
this._updateLibraryManageComponentProps(null, null, null, {
|
||||
items: libraries
|
||||
});
|
||||
},
|
||||
reason => {
|
||||
const parsedError = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(parsedError[0].message);
|
||||
}
|
||||
);
|
||||
super.open();
|
||||
}
|
||||
|
||||
public submit(): void {
|
||||
// override default behavior because this is not a form
|
||||
}
|
||||
|
||||
private _updateLibraryManageComponentProps(
|
||||
newNameProps?: Partial<LibraryAddNameTextFieldProps>,
|
||||
newUrlProps?: Partial<LibraryAddUrlTextFieldProps>,
|
||||
newButtonProps?: Partial<LibraryAddButtonProps>,
|
||||
newGridProps?: Partial<LibraryManageGridProps>
|
||||
): void {
|
||||
let {
|
||||
addProps: { buttonProps, nameProps, urlProps },
|
||||
gridProps
|
||||
} = this._libraryManageProps();
|
||||
if (newNameProps) {
|
||||
nameProps = { ...nameProps, ...newNameProps };
|
||||
}
|
||||
if (newUrlProps) {
|
||||
urlProps = { ...urlProps, ...newUrlProps };
|
||||
}
|
||||
if (newButtonProps) {
|
||||
buttonProps = { ...buttonProps, ...newButtonProps };
|
||||
}
|
||||
if (newGridProps) {
|
||||
gridProps = { ...gridProps, ...newGridProps };
|
||||
}
|
||||
this._libraryManageProps({
|
||||
addProps: {
|
||||
nameProps,
|
||||
urlProps,
|
||||
buttonProps
|
||||
},
|
||||
gridProps
|
||||
});
|
||||
this._libraryManageProps.valueHasMutated();
|
||||
}
|
||||
|
||||
private _onLibraryNameChange = (libraryName: string): void => {
|
||||
this._updateLibraryManageComponentProps({ libraryName });
|
||||
};
|
||||
|
||||
private _onLibraryNameValidated = (errorMessage: string): void => {
|
||||
this._libraryManageStates.isNameValid = !errorMessage;
|
||||
this._validateAddButton();
|
||||
};
|
||||
|
||||
private _onLibraryAddressChange = (libraryAddress: string): void => {
|
||||
this._updateLibraryManageComponentProps(null, {
|
||||
libraryAddress
|
||||
});
|
||||
|
||||
if (!this._libraryManageProps().addProps.nameProps.libraryName) {
|
||||
const parsedLibraryAddress = this._parseLibraryUrl(libraryAddress);
|
||||
if (!parsedLibraryAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
let libraryName = this._sanitizeLibraryName(parsedLibraryAddress[2]);
|
||||
this._updateLibraryManageComponentProps({ libraryName });
|
||||
}
|
||||
};
|
||||
|
||||
private _sanitizeLibraryName = (libraryName: string): string => {
|
||||
const invalidCharRegex = /[^a-zA-Z0-9-]/gm;
|
||||
return libraryName
|
||||
.replace(invalidCharRegex, "-")
|
||||
.substring(0, Math.min(Constants.SparkLibrary.nameMaxLength, libraryName.length));
|
||||
};
|
||||
|
||||
private _onLibraryAddressValidated = (errorMessage: string): void => {
|
||||
this._libraryManageStates.isUrlValid = !errorMessage;
|
||||
this._validateAddButton();
|
||||
};
|
||||
|
||||
private _validateAddButton = (): void => {
|
||||
const isValid = this._libraryManageStates.isNameValid && this._libraryManageStates.isUrlValid;
|
||||
const isUploadDisabled = this._libraryManageProps().addProps.buttonProps.disabled;
|
||||
if (isValid === isUploadDisabled) {
|
||||
this._updateLibraryManageComponentProps(null, null, {
|
||||
disabled: !isUploadDisabled
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _onLibraryDeleteClick = (libraryName: string): void => {
|
||||
const resourceId: string = this.container.databaseAccount() && this.container.databaseAccount().id;
|
||||
this.isExecuting(true);
|
||||
this._deleteLibrary(resourceId, libraryName).then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
const items = this._libraryManageProps().gridProps.items.filter(lib => lib.name !== libraryName);
|
||||
this._updateLibraryManageComponentProps(null, null, null, {
|
||||
items
|
||||
});
|
||||
},
|
||||
reason => {
|
||||
this.isExecuting(false);
|
||||
const parsedError = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(parsedError[0].message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
private _onLibraryAddClick = (): void => {
|
||||
const libraryAddress = this._libraryManageProps().addProps.urlProps.libraryAddress;
|
||||
if (!libraryAddress) {
|
||||
this.formErrors("Library Url cannot be null");
|
||||
return;
|
||||
}
|
||||
const libraryName = this._libraryManageProps().addProps.nameProps.libraryName || this._generateLibraryName();
|
||||
if (!libraryName) {
|
||||
this.formErrors("Library Name cannot be null");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedLibraryAddress = this._parseLibraryUrl(libraryAddress);
|
||||
if (!parsedLibraryAddress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const library: Library = {
|
||||
name: libraryName,
|
||||
properties: {
|
||||
kind: "Jar",
|
||||
source: {
|
||||
kind: "HttpsUri",
|
||||
libraryFileName: `${libraryName}.${parsedLibraryAddress[3]}`,
|
||||
uri: libraryAddress
|
||||
}
|
||||
}
|
||||
};
|
||||
const resourceId: string = this.container.databaseAccount() && this.container.databaseAccount().id;
|
||||
|
||||
this.isExecuting(true);
|
||||
this._updateLibraryManageComponentProps(null, null, { disabled: true });
|
||||
this._addLibrary(resourceId, library).then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
this._updateLibraryManageComponentProps(
|
||||
{
|
||||
libraryName: ""
|
||||
},
|
||||
{
|
||||
libraryAddress: ""
|
||||
},
|
||||
{
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
items: [...this._libraryManageProps().gridProps.items, library]
|
||||
}
|
||||
);
|
||||
},
|
||||
reason => {
|
||||
this.isExecuting(false);
|
||||
const parsedError = ErrorParserUtility.parse(reason);
|
||||
this.formErrors(parsedError[0].message);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
private _parseLibraryUrl = (url: string): RegExpExecArray => {
|
||||
const libraryUrlRegex = /^(https:\/\/.+\/)(.+)\.(jar)$/gi;
|
||||
return libraryUrlRegex.exec(url);
|
||||
};
|
||||
|
||||
private _generateLibraryName = (): string => {
|
||||
return `library-${Math.random()
|
||||
.toString(32)
|
||||
.substring(2)}`;
|
||||
};
|
||||
|
||||
private async _getLibraries(resourceId: string): Promise<Library[]> {
|
||||
if (!resourceId) {
|
||||
return Promise.reject("Invalid inputs");
|
||||
}
|
||||
|
||||
if (!this.container.sparkClusterManager) {
|
||||
return Promise.reject("Cluster client is not initialized yet");
|
||||
}
|
||||
|
||||
const inProgressId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Fetching libraries...`
|
||||
);
|
||||
try {
|
||||
const libraries = await this.container.sparkClusterManager.getLibrariesAsync(resourceId);
|
||||
return libraries;
|
||||
} catch (e) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to fetch libraries. Reason: ${JSON.stringify(e)}`
|
||||
);
|
||||
Logger.logError(e, "Explorer/_getLibraries");
|
||||
throw e;
|
||||
} finally {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressId);
|
||||
}
|
||||
}
|
||||
|
||||
private async _addLibrary(resourceId: string, library: Library): Promise<void> {
|
||||
if (!library || !resourceId) {
|
||||
return Promise.reject("invalid inputs");
|
||||
}
|
||||
|
||||
if (!this.container.sparkClusterManager) {
|
||||
return Promise.reject("cluster client is not initialized yet");
|
||||
}
|
||||
|
||||
TelemetryProcessor.traceStart(Action.LibraryManage, {
|
||||
resourceId,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
area: "LibraryManagePane/_deleteLibrary",
|
||||
libraryName: library.name
|
||||
});
|
||||
|
||||
const libraryName = library.name;
|
||||
const inProgressId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Uploading ${libraryName}...`
|
||||
);
|
||||
try {
|
||||
await this.container.sparkClusterManager.addLibraryAsync(resourceId, libraryName, library);
|
||||
TelemetryProcessor.traceSuccess(Action.LibraryManage, {
|
||||
resourceId,
|
||||
area: "LibraryManagePane/_deleteLibrary",
|
||||
libraryName: library.name
|
||||
});
|
||||
} catch (e) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to upload ${libraryName}. Reason: ${JSON.stringify(e)}`
|
||||
);
|
||||
TelemetryProcessor.traceFailure(Action.LibraryManage, {
|
||||
resourceId,
|
||||
area: "LibraryManagePane/_deleteLibrary",
|
||||
libraryName: library.name,
|
||||
error: e
|
||||
});
|
||||
Logger.logError(e, "Explorer/_uploadLibrary");
|
||||
throw e;
|
||||
} finally {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressId);
|
||||
}
|
||||
}
|
||||
|
||||
private async _deleteLibrary(resourceId: string, libraryName: string): Promise<void> {
|
||||
if (!libraryName || !resourceId) {
|
||||
return Promise.reject("invalid inputs");
|
||||
}
|
||||
|
||||
if (!this.container.sparkClusterManager) {
|
||||
return Promise.reject("cluster client is not initialized yet");
|
||||
}
|
||||
|
||||
TelemetryProcessor.traceStart(Action.LibraryManage, {
|
||||
resourceId,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
area: "LibraryManagePane/_deleteLibrary",
|
||||
libraryName
|
||||
});
|
||||
const inProgressId = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Deleting ${libraryName}...`
|
||||
);
|
||||
try {
|
||||
await this.container.sparkClusterManager.deleteLibraryAsync(resourceId, libraryName);
|
||||
TelemetryProcessor.traceSuccess(Action.LibraryManage, {
|
||||
resourceId,
|
||||
area: "LibraryManagePane/_deleteLibrary",
|
||||
libraryName
|
||||
});
|
||||
} catch (e) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to delete ${libraryName}. Reason: ${JSON.stringify(e)}`
|
||||
);
|
||||
TelemetryProcessor.traceFailure(Action.LibraryManage, {
|
||||
resourceId,
|
||||
area: "LibraryManagePane/_deleteLibrary",
|
||||
libraryName,
|
||||
error: e
|
||||
});
|
||||
Logger.logError(e, "Explorer/_deleteLibrary");
|
||||
throw e;
|
||||
} finally {
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(inProgressId);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/Explorer/Panes/LoadQueryPane.html
Normal file
88
src/Explorer/Panes/LoadQueryPane.html
Normal file
@@ -0,0 +1,88 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="loadQueryPane">
|
||||
<!-- Load Query form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Load Query header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Load Query header - End -->
|
||||
|
||||
<!-- Load Query errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Load Query errors - End -->
|
||||
|
||||
<!-- Load Query inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<div class="renewUploadItemsHeader">Select a query document</div>
|
||||
<input
|
||||
class="importFilesTitle"
|
||||
type="text"
|
||||
role="textbox"
|
||||
disabled
|
||||
data-bind="value: selectedFilesTitle"
|
||||
aria-label="Select a query document"
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id="importQueryInput"
|
||||
accept="text/plain"
|
||||
style="display: none"
|
||||
data-bind="event: { change: updateSelectedFiles }"
|
||||
/>
|
||||
<a
|
||||
href="#"
|
||||
id="queryFileImportLink"
|
||||
aria-label="Upload files"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
data-bind="event: { click: onImportLinkClick, keypress: onImportLinkKeyPress }"
|
||||
>
|
||||
<img class="fileImportImg" src="/folder_16x16.svg" alt="upload files" title="upload files" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Load" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- Load Query inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Load Query form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
150
src/Explorer/Panes/LoadQueryPane.ts
Normal file
150
src/Explorer/Panes/LoadQueryPane.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Q from "q";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { Logger } from "../../Common/Logger";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
export class LoadQueryPane extends ContextualPaneBase implements ViewModels.LoadQueryPane {
|
||||
public selectedFilesTitle: ko.Observable<string>;
|
||||
public files: ko.Observable<FileList>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Load Query");
|
||||
this.resetData();
|
||||
|
||||
this.selectedFilesTitle = ko.observable<string>("");
|
||||
this.files = ko.observable<FileList>();
|
||||
this.files.subscribe((newFiles: FileList) => this.updateSelectedFilesTitle(newFiles));
|
||||
const focusElement = document.getElementById("queryFileImportLink");
|
||||
focusElement && focusElement.focus();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
if (!this.files() || this.files().length === 0) {
|
||||
this.formErrors("No file specified");
|
||||
this.formErrorsDetails("No file specified. Please input a file.");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
"Could not load query -- No file specified. Please input a file."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const file: File = this.files().item(0);
|
||||
const id: string = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Loading query from file ${file.name}`
|
||||
);
|
||||
this.isExecuting(true);
|
||||
this.loadQueryFromFile(this.files().item(0))
|
||||
.then(
|
||||
() => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Successfully loaded query from file ${file.name}`
|
||||
);
|
||||
this.close();
|
||||
},
|
||||
(error: any) => {
|
||||
this.formErrors("Failed to load query");
|
||||
this.formErrorsDetails(`Failed to load query: ${error}`);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to load query from file ${file.name}: ${error}`
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(id);
|
||||
});
|
||||
}
|
||||
|
||||
public updateSelectedFiles(element: any, event: any): void {
|
||||
this.files(event.target.files);
|
||||
}
|
||||
|
||||
public open() {
|
||||
super.open();
|
||||
const focusElement = document.getElementById("queryFileImportLink");
|
||||
focusElement && focusElement.focus();
|
||||
}
|
||||
|
||||
public close() {
|
||||
super.close();
|
||||
this.resetData();
|
||||
this.files(undefined);
|
||||
this.resetFileInput();
|
||||
}
|
||||
|
||||
public onImportLinkClick(source: any, event: MouseEvent): boolean {
|
||||
document.getElementById("importQueryInput").click();
|
||||
return false;
|
||||
}
|
||||
|
||||
public onImportLinkKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.onImportLinkClick(source, null);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public loadQueryFromFile(file: File): Q.Promise<void> {
|
||||
const selectedCollection: ViewModels.Collection = this.container && this.container.findSelectedCollection();
|
||||
if (!selectedCollection) {
|
||||
// should never get into this state
|
||||
Logger.logError("No collection was selected", "LoadQueryPane.loadQueryFromFile");
|
||||
return Q.reject("No collection was selected");
|
||||
} else if (this.container.isPreferredApiMongoDB()) {
|
||||
selectedCollection.onNewMongoQueryClick(selectedCollection, null);
|
||||
} else {
|
||||
selectedCollection.onNewQueryClick(selectedCollection, null);
|
||||
}
|
||||
const deferred: Q.Deferred<void> = Q.defer<void>();
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt: any): void => {
|
||||
const fileData: string = evt.target.result;
|
||||
const queryTab: ViewModels.QueryTab = this.container.findActiveTab() as ViewModels.QueryTab;
|
||||
queryTab.initialEditorContent(fileData);
|
||||
queryTab.sqlQueryEditorContent(fileData);
|
||||
deferred.resolve();
|
||||
};
|
||||
|
||||
reader.onerror = (evt: ProgressEvent): void => {
|
||||
deferred.reject((evt as any).error.message);
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
private updateSelectedFilesTitle(fileList: FileList) {
|
||||
this.selectedFilesTitle("");
|
||||
|
||||
if (!fileList || fileList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const originalTitle = this.selectedFilesTitle();
|
||||
this.selectedFilesTitle(originalTitle + `"${fileList.item(i).name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private resetFileInput(): void {
|
||||
const inputElement = $("#importQueryInput");
|
||||
inputElement
|
||||
.wrap("<form>")
|
||||
.closest("form")
|
||||
.get(0)
|
||||
.reset();
|
||||
inputElement.unwrap();
|
||||
}
|
||||
}
|
||||
37
src/Explorer/Panes/ManageSparkClusterPane.html
Normal file
37
src/Explorer/Panes/ManageSparkClusterPane.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="managesparkclusterpane">
|
||||
<!-- Setup spark form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<div class="paneContentContainer">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Setup spark header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Setup spark header - End -->
|
||||
|
||||
<div class="paneMainContent"><div data-bind="react: clusterSettingsComponentAdapter"></div></div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Save" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Setup spark form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" alt="loading indicator image" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
153
src/Explorer/Panes/ManageSparkClusterPane.ts
Normal file
153
src/Explorer/Panes/ManageSparkClusterPane.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas } from "../../Common/Constants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ClusterSettingsComponentAdapter } from "../Controls/Spark/ClusterSettingsComponentAdapter";
|
||||
import { ClusterSettingsComponentProps } from "../Controls/Spark/ClusterSettingsComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { Spark } from "../../Common/Constants";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
const MAX_NUM_WORKERS = 10;
|
||||
|
||||
export class ManageSparkClusterPane extends ContextualPaneBase {
|
||||
public readonly maxWorkerCount = Spark.MaxWorkerCount;
|
||||
public workerCount: ko.Observable<number>;
|
||||
public clusterSettingsComponentAdapter: ClusterSettingsComponentAdapter;
|
||||
|
||||
private _settingsComponentAdapterProps: ko.Observable<ClusterSettingsComponentProps>;
|
||||
private _defaultCluster: ko.Observable<DataModels.SparkCluster>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Manage spark cluster");
|
||||
this.workerCount = ko.observable<number>();
|
||||
this._defaultCluster = ko.observable<DataModels.SparkCluster>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
type: undefined,
|
||||
properties: {
|
||||
kind: undefined,
|
||||
creationTime: undefined,
|
||||
driverSize: undefined,
|
||||
status: undefined,
|
||||
workerInstanceCount: undefined,
|
||||
workerSize: undefined
|
||||
}
|
||||
});
|
||||
this._settingsComponentAdapterProps = ko.observable<ClusterSettingsComponentProps>({
|
||||
cluster: this._defaultCluster(),
|
||||
onClusterSettingsChanged: this.onClusterSettingsChange
|
||||
});
|
||||
this._defaultCluster.subscribe(cluster => {
|
||||
this._settingsComponentAdapterProps().cluster = cluster;
|
||||
this._settingsComponentAdapterProps.valueHasMutated(); // trigger component re-render
|
||||
});
|
||||
this.clusterSettingsComponentAdapter = new ClusterSettingsComponentAdapter();
|
||||
this.clusterSettingsComponentAdapter.parameters = this._settingsComponentAdapterProps;
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
if (!this.workerCount() || this.workerCount() > MAX_NUM_WORKERS) {
|
||||
this.formErrors("Invalid worker count specified");
|
||||
this.formErrorsDetails(`The number of workers should be between 0 and ${MAX_NUM_WORKERS}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._defaultCluster()) {
|
||||
this.formErrors("No default cluster found");
|
||||
this.formErrorsDetails("No default cluster found to be associated with this account");
|
||||
return;
|
||||
}
|
||||
|
||||
const startKey = TelemetryProcessor.traceStart(Action.UpdateSparkCluster, {
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
const workerCount = this.workerCount();
|
||||
const id = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`Updating default cluster worker count to ${workerCount} nodes`
|
||||
);
|
||||
this.isExecuting(true);
|
||||
try {
|
||||
const databaseAccount = this.container && this.container.databaseAccount();
|
||||
const cluster = this._defaultCluster();
|
||||
cluster.properties.workerInstanceCount = workerCount;
|
||||
const updatedCluster =
|
||||
this.container &&
|
||||
(await this.container.sparkClusterManager.updateClusterAsync(
|
||||
databaseAccount && databaseAccount.id,
|
||||
cluster.name,
|
||||
cluster
|
||||
));
|
||||
this._defaultCluster(updatedCluster);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Successfully updated default cluster worker count to ${workerCount} nodes`
|
||||
);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.UpdateSparkCluster,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} catch (error) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to update default cluster worker count to ${workerCount} nodes: ${JSON.stringify(error)}`
|
||||
);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.UpdateSparkCluster,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
error: JSON.stringify(error)
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(id);
|
||||
}
|
||||
}
|
||||
|
||||
public onClusterSettingsChange = (cluster: DataModels.SparkCluster) => {
|
||||
this._defaultCluster(cluster);
|
||||
this.workerCount(
|
||||
(cluster &&
|
||||
cluster.properties &&
|
||||
cluster.properties.workerInstanceCount !== undefined &&
|
||||
cluster.properties.workerInstanceCount) ||
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
public async open() {
|
||||
const defaultCluster = await this.container.sparkClusterManager.getClusterAsync(
|
||||
this.container.databaseAccount().id,
|
||||
"default"
|
||||
);
|
||||
this._defaultCluster(defaultCluster);
|
||||
this.workerCount(
|
||||
(defaultCluster &&
|
||||
defaultCluster.properties &&
|
||||
defaultCluster.properties.workerInstanceCount !== undefined &&
|
||||
defaultCluster.properties.workerInstanceCount) ||
|
||||
0
|
||||
);
|
||||
super.open();
|
||||
}
|
||||
}
|
||||
64
src/Explorer/Panes/NewVertexPane.ts
Normal file
64
src/Explorer/Panes/NewVertexPane.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { KeyCodes } from "../../Common/Constants";
|
||||
|
||||
export default class NewVertexPane extends ContextualPaneBase implements ViewModels.NewVertexPane {
|
||||
public container: ViewModels.Explorer;
|
||||
public visible: ko.Observable<boolean>;
|
||||
public formErrors: ko.Observable<string>;
|
||||
public formErrorsDetails: ko.Observable<string>;
|
||||
|
||||
// Graph style stuff
|
||||
public tempVertexData: ko.Observable<ViewModels.NewVertexData>; // vertex data being edited
|
||||
private onSubmitCreateCallback: (newVertexData: ViewModels.NewVertexData) => void;
|
||||
private partitionKeyProperty: ko.Observable<string>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.tempVertexData = ko.observable<ViewModels.NewVertexData>(null);
|
||||
this.partitionKeyProperty = ko.observable(null);
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
// Commit edited changes
|
||||
if (this.onSubmitCreateCallback != null) {
|
||||
this.onSubmitCreateCallback(this.tempVertexData());
|
||||
}
|
||||
|
||||
// this.close();
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
super.resetData();
|
||||
|
||||
this.onSubmitCreateCallback = null;
|
||||
|
||||
this.tempVertexData({
|
||||
label: "",
|
||||
properties: <ViewModels.InputProperty[]>[]
|
||||
});
|
||||
this.partitionKeyProperty(null);
|
||||
}
|
||||
|
||||
public subscribeOnSubmitCreate(callback: (newVertexData: ViewModels.NewVertexData) => void): void {
|
||||
this.onSubmitCreateCallback = callback;
|
||||
}
|
||||
|
||||
public setPartitionKeyProperty(pKeyProp: string): void {
|
||||
this.partitionKeyProperty(pKeyProp);
|
||||
}
|
||||
|
||||
public onMoreDetailsKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
|
||||
this.showErrorDetails();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public buildString = (prefix: string, index: number): string => {
|
||||
return `${prefix}${index}`;
|
||||
};
|
||||
}
|
||||
266
src/Explorer/Panes/PaneComponents.ts
Normal file
266
src/Explorer/Panes/PaneComponents.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import AddDatabasePaneTemplate from "./AddDatabasePane.html";
|
||||
import AddCollectionPaneTemplate from "./AddCollectionPane.html";
|
||||
import DeleteCollectionConfirmationPaneTemplate from "./DeleteCollectionConfirmationPane.html";
|
||||
import DeleteDatabaseConfirmationPaneTemplate from "./DeleteDatabaseConfirmationPane.html";
|
||||
import GraphNewVertexPaneTemplate from "./GraphNewVertexPane.html";
|
||||
import GraphStylingPaneTemplate from "./GraphStylingPane.html";
|
||||
import TableAddEntityPaneTemplate from "./Tables/TableAddEntityPane.html";
|
||||
import TableEditEntityPaneTemplate from "./Tables/TableEditEntityPane.html";
|
||||
import TableColumnOptionsPaneTemplate from "./Tables/TableColumnOptionsPane.html";
|
||||
import TableQuerySelectPaneTemplate from "./Tables/TableQuerySelectPane.html";
|
||||
import CassandraAddCollectionPaneTemplate from "./CassandraAddCollectionPane.html";
|
||||
import SettingsPaneTemplate from "./SettingsPane.html";
|
||||
import ExecuteSprocParamsPaneTemplate from "./ExecuteSprocParamsPane.html";
|
||||
import RenewAdHocAccessPaneTemplate from "./RenewAdHocAccessPane.html";
|
||||
import UploadItemsPaneTemplate from "./UploadItemsPane.html";
|
||||
import LoadQueryPaneTemplate from "./LoadQueryPane.html";
|
||||
import SaveQueryPaneTemplate from "./SaveQueryPane.html";
|
||||
import BrowseQueriesPaneTemplate from "./BrowseQueriesPane.html";
|
||||
import UploadFilePaneTemplate from "./UploadFilePane.html";
|
||||
import StringInputPaneTemplate from "./StringInputPane.html";
|
||||
import SetupNotebooksPaneTemplate from "./SetupNotebooksPane.html";
|
||||
import SetupSparkClusterPaneTemplate from "./SetupSparkClusterPane.html";
|
||||
import ManageSparkClusterPaneTemplate from "./ManageSparkClusterPane.html";
|
||||
import LibraryManagePaneTemplate from "./LibraryManagePane.html";
|
||||
import ClusterLibraryPaneTemplate from "./ClusterLibraryPane.html";
|
||||
import GitHubReposPaneTemplate from "./GitHubReposPane.html";
|
||||
|
||||
export class PaneComponent {
|
||||
constructor(data: any) {
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export class AddDatabasePaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: AddDatabasePaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class AddCollectionPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: AddCollectionPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteCollectionConfirmationPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: DeleteCollectionConfirmationPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteDatabaseConfirmationPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: DeleteDatabaseConfirmationPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GraphNewVertexPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: GraphNewVertexPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GraphStylingPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: GraphStylingPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TableAddEntityPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: TableAddEntityPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TableEditEntityPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: TableEditEntityPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TableColumnOptionsPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: TableColumnOptionsPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class TableQuerySelectPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: TableQuerySelectPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class CassandraAddCollectionPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: CassandraAddCollectionPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: SettingsPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ExecuteSprocParamsComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: ExecuteSprocParamsPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class RenewAdHocAccessPane {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: RenewAdHocAccessPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadItemsPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: UploadItemsPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LoadQueryPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: LoadQueryPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SaveQueryPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: SaveQueryPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowseQueriesPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: BrowseQueriesPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class UploadFilePaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: UploadFilePaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class StringInputPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: StringInputPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupNotebooksPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: SetupNotebooksPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupSparkClusterPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: SetupSparkClusterPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ManageSparkClusterPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: ManageSparkClusterPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class LibraryManagePaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: LibraryManagePaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class ClusterLibraryPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: ClusterLibraryPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubReposPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: GitHubReposPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
90
src/Explorer/Panes/RenewAdHocAccessPane.html
Normal file
90
src/Explorer/Panes/RenewAdHocAccessPane.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="renewadhocaccesspane">
|
||||
<!-- Renew ad-hoc access form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Renew ad-hoc access header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Renew ad-hoc access header - End -->
|
||||
|
||||
<!-- Renew ad-hoc access errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="
|
||||
visible: formErrorsDetails() && formErrorsDetails() !== '',
|
||||
click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Renew ad-hoc access errors - End -->
|
||||
|
||||
<!-- Renew ad-hoc access inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div class="renewUploadItemsHeader">Provide a valid account connection string</div>
|
||||
<input
|
||||
class="accessKeyInput"
|
||||
type="text"
|
||||
placeholder="Enter a connection string"
|
||||
required
|
||||
data-bind="value: accessKey"
|
||||
/>
|
||||
<div
|
||||
class="renewAccessExpandCollapse"
|
||||
data-bind="click: onShowHelperImageClick, event: { keypress: onShowHelperImageKeyPress }"
|
||||
>
|
||||
<img src="/Triangle-right.svg" alt="Show renew access image" data-bind="visible: !isHelperImageVisible()" />
|
||||
<img src="/Triangle-down.svg" alt="Hide renew access image" data-bind="visible: isHelperImageVisible()" />
|
||||
<span class="AccountNavigationText">Where do I find the Connection String?</span>
|
||||
</div>
|
||||
<div class="renewAccessImg" data-bind="visible: isHelperImageVisible()">
|
||||
<span class="AccountNavigationText"
|
||||
>To get the connection string, navigate to your Azure Cosmos DB account in Azure Portal, select Keys and
|
||||
copy the connection string.</span
|
||||
>
|
||||
<img src="/ConnectionString_Artwork.png" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Connect" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- Renew ad-hoc access - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Renew ad-hoc access form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
102
src/Explorer/Panes/RenewAdHocAccessPane.ts
Normal file
102
src/Explorer/Panes/RenewAdHocAccessPane.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ConnectionStringParser } from "../../Platform/Hosted/Helpers/ConnectionStringParser";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { DefaultExperienceUtility } from "../../Shared/DefaultExperienceUtility";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
export class RenewAdHocAccessPane extends ContextualPaneBase implements ViewModels.RenewAdHocAccessPane {
|
||||
public accessKey: ko.Observable<string>;
|
||||
public isHelperImageVisible: ko.Observable<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Connect to Azure Cosmos DB");
|
||||
this.accessKey = ko.observable<string>();
|
||||
this.isHelperImageVisible = ko.observable<boolean>(false);
|
||||
}
|
||||
|
||||
public submit(): void {
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
|
||||
if (this._shouldShowContextSwitchPrompt()) {
|
||||
this.container.displayContextSwitchPromptForConnectionString(this.accessKey());
|
||||
} else if (!!this.formErrors()) {
|
||||
return;
|
||||
} else {
|
||||
this.isExecuting(true);
|
||||
this._renewShareAccess();
|
||||
}
|
||||
}
|
||||
|
||||
public onShowHelperImageClick = (src: any, event: MouseEvent): void => {
|
||||
this.isHelperImageVisible(!this.isHelperImageVisible());
|
||||
};
|
||||
|
||||
public onShowHelperImageKeyPress = (src: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.onShowHelperImageClick(src, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
private _shouldShowContextSwitchPrompt(): boolean {
|
||||
const inputMetadata: DataModels.AccessInputMetadata = ConnectionStringParser.parseConnectionString(
|
||||
this.accessKey()
|
||||
);
|
||||
const apiKind: DataModels.ApiKind =
|
||||
this.container && DefaultExperienceUtility.getApiKindFromDefaultExperience(this.container.defaultExperience());
|
||||
const hasOpenedTabs: boolean =
|
||||
(this.container && this.container.openedTabs() && this.container.openedTabs().length > 0) || false;
|
||||
|
||||
if (!inputMetadata || inputMetadata.apiKind == null || !inputMetadata.accountName) {
|
||||
this.formErrors("Invalid connection string input");
|
||||
this.formErrorsDetails("Please enter a valid connection string");
|
||||
}
|
||||
|
||||
if (
|
||||
!inputMetadata ||
|
||||
this.formErrors() ||
|
||||
!this.container ||
|
||||
apiKind == null ||
|
||||
!this.container.databaseAccount ||
|
||||
!this.container.defaultExperience ||
|
||||
!hasOpenedTabs ||
|
||||
(this.container.databaseAccount().name === inputMetadata.accountName &&
|
||||
apiKind === inputMetadata.apiKind &&
|
||||
!hasOpenedTabs)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _renewShareAccess = (): void => {
|
||||
this.container
|
||||
.renewShareAccess(this.accessKey())
|
||||
.fail((error: any) => {
|
||||
const errorMessage: string = JSON.stringify(error);
|
||||
NotificationConsoleUtils.logConsoleMessage(ConsoleDataType.Error, `Failed to connect: ${errorMessage}`);
|
||||
this.formErrors(errorMessage);
|
||||
this.formErrorsDetails(errorMessage);
|
||||
})
|
||||
.finally(() => {
|
||||
this.isExecuting(false);
|
||||
});
|
||||
};
|
||||
|
||||
public close(): void {
|
||||
super.close();
|
||||
this.isHelperImageVisible(false);
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
this.accessKey("");
|
||||
}
|
||||
}
|
||||
63
src/Explorer/Panes/SaveQueryPane.html
Normal file
63
src/Explorer/Panes/SaveQueryPane.html
Normal file
@@ -0,0 +1,63 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="savequerypane">
|
||||
<!-- Save Query form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Save Query header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div class="closeImg" role="button" aria-label="Close pane" tabindex="0" data-bind="click: cancel">
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Query header - End -->
|
||||
|
||||
<!-- Save Query errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Save Query errors - End -->
|
||||
|
||||
<!-- Save Query inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div class="pkPadding" data-bind="visible: !canSaveQueries()">
|
||||
<div data-bind="text: setupSaveQueriesText"></div>
|
||||
<button class="btncreatecoll1 btnSetupQueries" type="button" data-bind="click: setupQueries">
|
||||
Complete setup
|
||||
</button>
|
||||
</div>
|
||||
<div class="pkPadding" data-bind="visible: canSaveQueries">
|
||||
<p><span class="mandatoryStar">*</span> <span>Name</span></p>
|
||||
<input class="textfontclr collid" required type="text" data-bind="value: queryName" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter" data-bind="visible: canSaveQueries">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Save" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- Save Query inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Save Query form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
160
src/Explorer/Panes/SaveQueryPane.ts
Normal file
160
src/Explorer/Panes/SaveQueryPane.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as DataModels from "../../Contracts/DataModels";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export class SaveQueryPane extends ContextualPaneBase {
|
||||
public queryName: ko.Observable<string>;
|
||||
public canSaveQueries: ko.Computed<boolean>;
|
||||
public setupSaveQueriesText: string = `For compliance reasons, we save queries in a container in your Azure Cosmos account, in a separate database called “${Constants.SavedQueries.DatabaseName}”. To proceed, we need to create a container in your account, estimated additional cost is $0.77 daily.`;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Save Query");
|
||||
this.queryName = ko.observable<string>();
|
||||
this.canSaveQueries = this.container && this.container.canSaveQueries;
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public submit = (): void => {
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
if (!this.canSaveQueries()) {
|
||||
this.formErrors("Cannot save query");
|
||||
this.formErrorsDetails("Failed to save query: account not set up to save queries");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
"Failed to save query: account not setup to save queries"
|
||||
);
|
||||
}
|
||||
|
||||
const queryName: string = this.queryName();
|
||||
const queryTab: ViewModels.QueryTab = this.container && (this.container.findActiveTab() as ViewModels.QueryTab);
|
||||
const query: string = queryTab && queryTab.sqlQueryEditorContent();
|
||||
if (!queryName || queryName.length === 0) {
|
||||
this.formErrors("No query name specified");
|
||||
this.formErrorsDetails("No query name specified. Please specify a query name.");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
"Could not save query -- No query name specified. Please specify a query name."
|
||||
);
|
||||
return;
|
||||
} else if (!query || query.length === 0) {
|
||||
this.formErrors("Invalid query content specified");
|
||||
this.formErrorsDetails("Invalid query content specified. Please enter query content.");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
"Could not save query -- Invalid query content specified. Please enter query content."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryParam: DataModels.Query = {
|
||||
id: queryName,
|
||||
resourceId: this.container.queriesClient.getResourceId(),
|
||||
queryName: queryName,
|
||||
query: query
|
||||
};
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.SaveQuery, {
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
this.isExecuting(true);
|
||||
this.container.queriesClient.saveQuery(queryParam).then(
|
||||
() => {
|
||||
this.isExecuting(false);
|
||||
queryTab.tabTitle(queryParam.queryName);
|
||||
queryTab.tabPath(`${queryTab.collection.databaseId}>${queryTab.collection.id()}>${queryParam.queryName}`);
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.SaveQuery,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.close();
|
||||
},
|
||||
(error: any) => {
|
||||
this.isExecuting(false);
|
||||
if (typeof error != "string") {
|
||||
error = JSON.stringify(error);
|
||||
}
|
||||
this.formErrors("Failed to save query");
|
||||
this.formErrorsDetails(`Failed to save query: ${error}`);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.SaveQuery,
|
||||
{
|
||||
databaseAccountName: this.container.databaseAccount().name,
|
||||
defaultExperience: this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
public setupQueries = async (src: any, event: MouseEvent): Promise<void> => {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.SetupSavedQueries, {
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
try {
|
||||
this.isExecuting(true);
|
||||
await this.container.queriesClient.setupQueriesCollection();
|
||||
this.container.refreshAllDatabases();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.SetupSavedQueries,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
} catch (error) {
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.SetupSavedQueries,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Constants.Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.formErrors("Failed to setup a container for saved queries");
|
||||
this.formErrors(`Failed to setup a container for saved queries: ${JSON.stringify(error)}`);
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
public close() {
|
||||
super.close();
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
super.resetData();
|
||||
this.queryName("");
|
||||
}
|
||||
}
|
||||
268
src/Explorer/Panes/SettingsPane.html
Normal file
268
src/Explorer/Panes/SettingsPane.html
Normal file
@@ -0,0 +1,268 @@
|
||||
<!-- TODO: Move Pane to REACT -->
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="settingspane">
|
||||
<!-- Settings Confirmation form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Settings Confirmation header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keydown: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings Confirmation header - End -->
|
||||
|
||||
<!-- Settings Confirmation errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="
|
||||
visible: formErrorsDetails() && formErrorsDetails() !== '',
|
||||
click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Settings Confirmation errors - End -->
|
||||
|
||||
<!-- Settings Confirmation inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<div class="settingsSection" data-bind="visible: shouldShowQueryPageOptions">
|
||||
<div class="settingsSectionPart pageOptionsPart">
|
||||
<div class="settingsSectionLabel">
|
||||
Page options
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext pageOptionTooltipWidth">
|
||||
Choose Custom to specify a fixed amount of query results to show, or choose Unlimited to show as
|
||||
many query results per page.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tabs" role="radiogroup" aria-label="Page options">
|
||||
<!-- Fixed option button - Start -->
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="customItemPerPage"
|
||||
name="pageOption"
|
||||
value="custom"
|
||||
data-bind="checked: pageOption"
|
||||
/>
|
||||
<label
|
||||
for="customItemPerPage"
|
||||
id="custom-selection"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind=" attr:{
|
||||
'aria-checked': pageOption() === 'custom' ? 'true' : 'false' },
|
||||
event: { keydown: onCustomPageOptionsKeyDown
|
||||
}"
|
||||
>Custom</label
|
||||
>
|
||||
</div>
|
||||
<!-- Fixed option button - End -->
|
||||
|
||||
<!-- Unlimited option button - Start -->
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="unlimitedItemPerPage"
|
||||
name="pageOption"
|
||||
value="unlimited"
|
||||
data-bind="checked: pageOption"
|
||||
/>
|
||||
<label
|
||||
for="unlimitedItemPerPage"
|
||||
id="unlimited-selection"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind=" attr:{
|
||||
'aria-checked': pageOption() === 'unlimited' ? 'true' : 'false' },
|
||||
event: { keydown: onUnlimitedPageOptionKeyDown
|
||||
}"
|
||||
>Unlimited</label
|
||||
>
|
||||
</div>
|
||||
<!-- Unlimited option button - End -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs settingsSectionPart">
|
||||
<div class="tabcontent" data-bind="visible: isCustomPageOptionSelected()">
|
||||
<div class="settingsSectionLabel">
|
||||
Query results per page
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext pageOptionTooltipWidth">
|
||||
Enter the number of query results that should be shown per page.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="1"
|
||||
step="1"
|
||||
class="textfontclr collid"
|
||||
aria-label="Custom query items per page"
|
||||
data-bind="textInput: customItemPerPage, enable: isCustomPageOptionSelected()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsSection" data-bind="visible: shouldShowCrossPartitionOption">
|
||||
<div class="settingsSectionPart">
|
||||
<div class="settingsSectionLabel">
|
||||
Enable cross-partition query
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext pageOptionTooltipWidth">
|
||||
Send more than one request while executing a query. More than one request is necessary if the
|
||||
query is not scoped to single partition key value.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
tabindex="0"
|
||||
aria-label="Enable cross partition query"
|
||||
data-bind="checked: crossPartitionQueryEnabled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsSection" data-bind="visible: shouldShowParallelismOption">
|
||||
<div class="settingsSectionPart">
|
||||
<div class="settingsSectionLabel">
|
||||
Max degree of parallelism
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext pageOptionTooltipWidth">
|
||||
Gets or sets the number of concurrent operations run client side during parallel query execution.
|
||||
A positive property value limits the number of concurrent operations to the set value. If it is
|
||||
set to less than 0, the system automatically decides the number of concurrent operations to run.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min="-1"
|
||||
step="1"
|
||||
class="textfontclr collid"
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
id="max-degree"
|
||||
aria-label="Max degree of parallelism"
|
||||
data-bind="value: maxDegreeOfParallelism"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsSection" data-bind="visible: shouldShowGraphAutoVizOption">
|
||||
<div class="settingsSectionPart">
|
||||
<div class="settingsSectionLabel">
|
||||
Display Gremlin query results as:
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="tooltiptext pageOptionTooltipWidth">
|
||||
Select Graph to automatically visualize the query results as a Graph or JSON to display the
|
||||
results as JSON.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="tabs" role="radiogroup" aria-label="Graph Auto-visualization">
|
||||
<!-- Fixed option button - Start -->
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="graphAutoVizOn"
|
||||
name="graphAutoVizOption"
|
||||
value="false"
|
||||
data-bind="checked: graphAutoVizDisabled"
|
||||
/>
|
||||
<label
|
||||
for="graphAutoVizOn"
|
||||
id="graph-display"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr: { 'aria-checked': graphAutoVizDisabled() === 'false' ? 'true' : 'false' },
|
||||
event: { keypress: onGraphDisplayResultsKeyDown
|
||||
}"
|
||||
>Graph</label
|
||||
>
|
||||
</div>
|
||||
<!-- Fixed option button - End -->
|
||||
|
||||
<!-- Unlimited option button - Start -->
|
||||
<div class="tab">
|
||||
<input
|
||||
type="radio"
|
||||
id="graphAutoVizOff"
|
||||
name="graphAutoVizOption"
|
||||
value="true"
|
||||
data-bind="checked: graphAutoVizDisabled"
|
||||
/>
|
||||
<label
|
||||
for="graphAutoVizOff"
|
||||
tabindex="0"
|
||||
role="radio"
|
||||
data-bind="
|
||||
attr: { 'aria-checked': graphAutoVizDisabled() === 'true' ? 'true' : 'false' },
|
||||
event: { keypress: onJsonDisplayResultsKeyDown
|
||||
}"
|
||||
>JSON</label
|
||||
>
|
||||
</div>
|
||||
<!-- Unlimited option button - End -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settingsSection">
|
||||
<div class="settingsSectionPart">
|
||||
<div class="settingsSectionLabel">Explorer Version</div>
|
||||
|
||||
<div data-bind="text: explorerVersion"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Apply" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- Settings Confirmation inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Settings Confirmation form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
40
src/Explorer/Panes/SettingsPane.test.ts
Normal file
40
src/Explorer/Panes/SettingsPane.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import Explorer from "../Explorer";
|
||||
|
||||
jest.mock("../Tabs/NotebookTab");
|
||||
|
||||
describe("Settings Pane", () => {
|
||||
describe("shouldShowQueryPageOptions()", () => {
|
||||
let explorer: ViewModels.Explorer;
|
||||
|
||||
beforeEach(() => {
|
||||
explorer = new Explorer({ documentClientUtility: null, notificationsClient: null, isEmulator: false });
|
||||
});
|
||||
|
||||
it("should be true for SQL API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.DocumentDB.toLowerCase());
|
||||
expect(explorer.settingsPane.shouldShowQueryPageOptions()).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false for Cassandra API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.Cassandra.toLowerCase());
|
||||
expect(explorer.settingsPane.shouldShowQueryPageOptions()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false for Tables API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.Table.toLowerCase());
|
||||
expect(explorer.settingsPane.shouldShowQueryPageOptions()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false for Graph API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.Graph.toLowerCase());
|
||||
expect(explorer.settingsPane.shouldShowQueryPageOptions()).toBe(false);
|
||||
});
|
||||
|
||||
it("should be false for Mongo API", () => {
|
||||
explorer.defaultExperience(Constants.DefaultAccountExperience.MongoDB.toLowerCase());
|
||||
expect(explorer.settingsPane.shouldShowQueryPageOptions()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
185
src/Explorer/Panes/SettingsPane.ts
Normal file
185
src/Explorer/Panes/SettingsPane.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { StringUtility } from "../../Shared/StringUtility";
|
||||
import { config } from "../../Config";
|
||||
|
||||
export class SettingsPane extends ContextualPaneBase implements ViewModels.SettingsPane {
|
||||
public pageOption: ko.Observable<string>;
|
||||
public customItemPerPage: ko.Observable<number>;
|
||||
public crossPartitionQueryEnabled: ko.Observable<boolean>;
|
||||
public maxDegreeOfParallelism: ko.Observable<number>;
|
||||
public explorerVersion: string;
|
||||
public shouldShowQueryPageOptions: ko.Computed<boolean>;
|
||||
public shouldShowGraphAutoVizOption: ko.Computed<boolean>;
|
||||
|
||||
private graphAutoVizDisabled: ko.Observable<string>;
|
||||
private shouldShowCrossPartitionOption: ko.Computed<boolean>;
|
||||
private shouldShowParallelismOption: ko.Computed<boolean>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Settings");
|
||||
this.resetData();
|
||||
|
||||
this.pageOption = ko.observable<string>();
|
||||
this.customItemPerPage = ko.observable<number>();
|
||||
|
||||
const crossPartitionQueryEnabledState: boolean = LocalStorageUtility.hasItem(
|
||||
StorageKey.IsCrossPartitionQueryEnabled
|
||||
)
|
||||
? LocalStorageUtility.getEntryString(StorageKey.IsCrossPartitionQueryEnabled) === "true"
|
||||
: true;
|
||||
this.crossPartitionQueryEnabled = ko.observable<boolean>(crossPartitionQueryEnabledState);
|
||||
|
||||
const maxDegreeOfParallelismState: number = LocalStorageUtility.hasItem(StorageKey.MaxDegreeOfParellism)
|
||||
? LocalStorageUtility.getEntryNumber(StorageKey.MaxDegreeOfParellism)
|
||||
: Constants.Queries.DefaultMaxDegreeOfParallelism;
|
||||
this.maxDegreeOfParallelism = ko.observable<number>(maxDegreeOfParallelismState);
|
||||
|
||||
const isGraphAutoVizDisabled: boolean = LocalStorageUtility.hasItem(StorageKey.IsGraphAutoVizDisabled)
|
||||
? LocalStorageUtility.getEntryBoolean(StorageKey.IsGraphAutoVizDisabled)
|
||||
: false;
|
||||
this.graphAutoVizDisabled = ko.observable<string>(`${isGraphAutoVizDisabled}`);
|
||||
|
||||
this.explorerVersion = config.gitSha;
|
||||
this.shouldShowQueryPageOptions = ko.computed<boolean>(() => this.container.isPreferredApiDocumentDB());
|
||||
this.shouldShowCrossPartitionOption = ko.computed<boolean>(() => !this.container.isPreferredApiGraph());
|
||||
this.shouldShowParallelismOption = ko.computed<boolean>(() => !this.container.isPreferredApiGraph());
|
||||
this.shouldShowGraphAutoVizOption = ko.computed<boolean>(() => this.container.isPreferredApiGraph());
|
||||
}
|
||||
|
||||
public open() {
|
||||
this._loadSettings();
|
||||
super.open();
|
||||
const pageOptionsFocus = document.getElementById("custom-selection");
|
||||
const displayQueryFocus = document.getElementById("graph-display");
|
||||
const maxDegreeFocus = document.getElementById("max-degree");
|
||||
if (this.container.isPreferredApiGraph()) {
|
||||
displayQueryFocus && displayQueryFocus.focus();
|
||||
} else if (this.container.isPreferredApiTable()) {
|
||||
maxDegreeFocus && maxDegreeFocus.focus();
|
||||
}
|
||||
pageOptionsFocus && pageOptionsFocus.focus();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.formErrors("");
|
||||
this.isExecuting(true);
|
||||
|
||||
LocalStorageUtility.setEntryNumber(
|
||||
StorageKey.ActualItemPerPage,
|
||||
this.isCustomPageOptionSelected() ? this.customItemPerPage() : Constants.Queries.unlimitedItemsPerPage
|
||||
);
|
||||
LocalStorageUtility.setEntryNumber(StorageKey.CustomItemPerPage, this.customItemPerPage());
|
||||
LocalStorageUtility.setEntryString(
|
||||
StorageKey.IsCrossPartitionQueryEnabled,
|
||||
this.crossPartitionQueryEnabled().toString()
|
||||
);
|
||||
LocalStorageUtility.setEntryNumber(StorageKey.MaxDegreeOfParellism, this.maxDegreeOfParallelism());
|
||||
|
||||
if (this.shouldShowGraphAutoVizOption()) {
|
||||
LocalStorageUtility.setEntryBoolean(
|
||||
StorageKey.IsGraphAutoVizDisabled,
|
||||
StringUtility.toBoolean(this.graphAutoVizDisabled())
|
||||
);
|
||||
}
|
||||
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Updated items per page setting to ${LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage)}`
|
||||
);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`${this.crossPartitionQueryEnabled() ? "Enabled" : "Disabled"} cross-partition query feed option`
|
||||
);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Updated the max degree of parallelism query feed option to ${LocalStorageUtility.getEntryNumber(
|
||||
StorageKey.MaxDegreeOfParellism
|
||||
)}`
|
||||
);
|
||||
|
||||
if (this.shouldShowGraphAutoVizOption()) {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Graph result will be displayed as ${
|
||||
LocalStorageUtility.getEntryBoolean(StorageKey.IsGraphAutoVizDisabled) ? "JSON" : "Graph"
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`Updated query setting to ${LocalStorageUtility.getEntryString(StorageKey.SetPartitionKeyUndefined)}`
|
||||
);
|
||||
|
||||
this.close();
|
||||
}
|
||||
|
||||
public isCustomPageOptionSelected = (): boolean => {
|
||||
return this.pageOption() === Constants.Queries.CustomPageOption;
|
||||
};
|
||||
|
||||
public isUnlimitedPageOptionSelected = (): boolean => {
|
||||
return this.pageOption() === Constants.Queries.UnlimitedPageOption;
|
||||
};
|
||||
|
||||
public onUnlimitedPageOptionKeyDown(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.pageOption(Constants.Queries.UnlimitedPageOption);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public onCustomPageOptionsKeyDown(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.pageOption(Constants.Queries.CustomPageOption);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public onJsonDisplayResultsKeyDown(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.graphAutoVizDisabled("true");
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public onGraphDisplayResultsKeyDown(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.graphAutoVizDisabled("false");
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _loadSettings() {
|
||||
this.isExecuting(true);
|
||||
try {
|
||||
this.pageOption(
|
||||
LocalStorageUtility.getEntryNumber(StorageKey.ActualItemPerPage) == Constants.Queries.unlimitedItemsPerPage
|
||||
? Constants.Queries.UnlimitedPageOption
|
||||
: Constants.Queries.CustomPageOption
|
||||
);
|
||||
this.customItemPerPage(LocalStorageUtility.getEntryNumber(StorageKey.CustomItemPerPage));
|
||||
} catch (exception) {
|
||||
this.formErrors("Unable to load your settings");
|
||||
this.formErrorsDetails(exception);
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/Explorer/Panes/SetupNotebooksPane.html
Normal file
45
src/Explorer/Panes/SetupNotebooksPane.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="setupnotebookspane">
|
||||
<!-- Setup notebooks form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<div class="paneContentContainer">
|
||||
<!-- Setup notebooks header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Setup notebooks header - End -->
|
||||
|
||||
<div class="paneMainContent">
|
||||
<div class="pkPadding">
|
||||
<div data-bind="text: description"></div>
|
||||
<button
|
||||
id="completeSetupBtn"
|
||||
class="btncreatecoll1 btnSetupQueries"
|
||||
type="button"
|
||||
aria-label="Complete setup"
|
||||
data-bind="click: onCompleteSetupClick, event: { keypress: onCompleteSetupKeyPress }"
|
||||
>
|
||||
Complete setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Setup notebooks form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" alt="loading indicator image" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
111
src/Explorer/Panes/SetupNotebooksPane.ts
Normal file
111
src/Explorer/Panes/SetupNotebooksPane.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas, KeyCodes } from "../../Common/Constants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
import * as ko from "knockout";
|
||||
|
||||
export class SetupNotebooksPane extends ContextualPaneBase {
|
||||
private description: ko.Observable<string>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
|
||||
this.description = ko.observable<string>();
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public openWithTitleAndDescription(title: string, description: string) {
|
||||
this.title(title);
|
||||
this.description(description);
|
||||
|
||||
this.open();
|
||||
}
|
||||
|
||||
public open() {
|
||||
super.open();
|
||||
const completeSetupBtn = document.getElementById("completeSetupBtn");
|
||||
completeSetupBtn && completeSetupBtn.focus();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
// override default behavior because this is not a form
|
||||
}
|
||||
|
||||
public onCompleteSetupClick = async (src: any, event: MouseEvent) => {
|
||||
await this.setupNotebookWorkspace();
|
||||
};
|
||||
|
||||
public onCompleteSetupKeyPress = async (src: any, event: KeyboardEvent) => {
|
||||
if (event.keyCode === KeyCodes.Space || event.keyCode === KeyCodes.Enter) {
|
||||
await this.setupNotebookWorkspace();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public async setupNotebookWorkspace(): Promise<void> {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateNotebookWorkspace, {
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
const id = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
"Creating a new default notebook workspace"
|
||||
);
|
||||
try {
|
||||
this.isExecuting(true);
|
||||
await this.container.notebookWorkspaceManager.createNotebookWorkspaceAsync(
|
||||
this.container.databaseAccount() && this.container.databaseAccount().id,
|
||||
"default"
|
||||
);
|
||||
this.container.isAccountReady.valueHasMutated(); // re-trigger init notebooks
|
||||
this.close();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateNotebookWorkspace,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
"Successfully created a default notebook workspace for the account"
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = typeof error == "string" ? error : JSON.stringify(error);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateNotebookWorkspace,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
error: errorMessage
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.formErrors("Failed to setup a default notebook workspace");
|
||||
this.formErrorsDetails(`Failed to setup a default notebook workspace: ${errorMessage}`);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to create a default notebook workspace: ${errorMessage}`
|
||||
);
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
52
src/Explorer/Panes/SetupSparkClusterPane.html
Normal file
52
src/Explorer/Panes/SetupSparkClusterPane.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="setupsparkclusterpane">
|
||||
<!-- Setup spark form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<div class="paneContentContainer">
|
||||
<!-- Setup spark header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="click: cancel, event: { keypress: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Setup spark header - End -->
|
||||
|
||||
<div class="paneMainContent">
|
||||
<div class="pkPadding">
|
||||
<div data-bind="text: setupSparkClusterText"></div>
|
||||
<div class="seconddivpadding">
|
||||
<div>Worker Count</div>
|
||||
<input
|
||||
class="sparkWorkerCountInput"
|
||||
type="number"
|
||||
min="1"
|
||||
aria-label="worker count input"
|
||||
required
|
||||
data-bind="textInput: workerCount, attr: { max: maxWorkerNodes }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Complete Setup" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Setup spark form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" alt="loading indicator image" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
99
src/Explorer/Panes/SetupSparkClusterPane.ts
Normal file
99
src/Explorer/Panes/SetupSparkClusterPane.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { Action } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { Areas } from "../../Common/Constants";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import { Spark } from "../../Common/Constants";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
export class SetupSparkClusterPane extends ContextualPaneBase {
|
||||
public setupSparkClusterText: string =
|
||||
"Looks like you have not yet created a default spark cluster for this account. To proceed and start using notebooks with spark, we'll need to create a default spark cluster for this account.";
|
||||
public readonly maxWorkerNodes: number = Spark.MaxWorkerCount;
|
||||
public workerCount: ko.Observable<number>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.title("Enable spark");
|
||||
this.workerCount = ko.observable<number>(1);
|
||||
this.resetData();
|
||||
}
|
||||
|
||||
public async submit() {
|
||||
await this.setupSparkCluster();
|
||||
}
|
||||
|
||||
public async setupSparkCluster(): Promise<void> {
|
||||
if (!this.container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startKey: number = TelemetryProcessor.traceStart(Action.CreateSparkCluster, {
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
});
|
||||
const id = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
"Creating a new default spark cluster"
|
||||
);
|
||||
try {
|
||||
this.isExecuting(true);
|
||||
await this.container.sparkClusterManager.createClusterAsync(
|
||||
this.container.databaseAccount() && this.container.databaseAccount().id,
|
||||
{
|
||||
name: "default",
|
||||
properties: {
|
||||
kind: "Spark",
|
||||
workerInstanceCount: this.workerCount(),
|
||||
driverSize: "Cosmos.Spark.D4s",
|
||||
workerSize: "Cosmos.Spark.D4s",
|
||||
creationTime: undefined,
|
||||
status: undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
this.container.isAccountReady.valueHasMutated(); // refresh internal state
|
||||
this.close();
|
||||
TelemetryProcessor.traceSuccess(
|
||||
Action.CreateSparkCluster,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title()
|
||||
},
|
||||
startKey
|
||||
);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
"Successfully created a default spark cluster for the account"
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = typeof error == "string" ? error : JSON.stringify(error);
|
||||
TelemetryProcessor.traceFailure(
|
||||
Action.CreateSparkCluster,
|
||||
{
|
||||
databaseAccountName: this.container && this.container.databaseAccount().name,
|
||||
defaultExperience: this.container && this.container.defaultExperience(),
|
||||
dataExplorerArea: Areas.ContextualPane,
|
||||
paneTitle: this.title(),
|
||||
error: errorMessage
|
||||
},
|
||||
startKey
|
||||
);
|
||||
this.formErrors("Failed to setup a default spark cluster");
|
||||
this.formErrorsDetails(`Failed to setup a default spark cluster: ${errorMessage}`);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`Failed to create a default spark cluster: ${errorMessage}`
|
||||
);
|
||||
} finally {
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/Explorer/Panes/StringInputPane.html
Normal file
66
src/Explorer/Panes/StringInputPane.html
Normal file
@@ -0,0 +1,66 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="stringInputPane">
|
||||
<!-- String Input form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- String Input header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div class="closeImg" role="button" aria-label="Close pane" tabindex="0" data-bind="click: cancel"></div>
|
||||
</div>
|
||||
<!-- String Input header - End -->
|
||||
|
||||
<!-- String Input errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- String Input errors - End -->
|
||||
|
||||
<!-- String Input inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<p data-bind="text: inputLabel"></p>
|
||||
<p>
|
||||
<input
|
||||
type="text"
|
||||
name="collectionIdConfirmation"
|
||||
required
|
||||
class="collid"
|
||||
data-bind="textInput: stringInput, hasFocus: firstFieldHasFocus"
|
||||
aria-label="inputLabel"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input type="submit" data-bind="attr: { value: submitButtonLabel }" class="btncreatecoll1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- String Input inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- String Input form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
98
src/Explorer/Panes/StringInputPane.ts
Normal file
98
src/Explorer/Panes/StringInputPane.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as ko from "knockout";
|
||||
import Q from "q";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
/**
|
||||
* Generic pane to get a single string input from user
|
||||
*/
|
||||
export class StringInputPane extends ContextualPaneBase {
|
||||
private openOptions: ViewModels.StringInputPaneOpenOptions;
|
||||
private submitButtonLabel: ko.Observable<string>;
|
||||
private inputLabel: ko.Observable<string>;
|
||||
private stringInput: ko.Observable<string>;
|
||||
|
||||
private paneDeferred: Q.Deferred<any>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.resetData();
|
||||
this.inputLabel = ko.observable("");
|
||||
this.submitButtonLabel = ko.observable("Load");
|
||||
this.stringInput = ko.observable();
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
|
||||
const id: string = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`${this.openOptions.inProgressMessage} ${this.stringInput()}`
|
||||
);
|
||||
this.isExecuting(true);
|
||||
this.openOptions
|
||||
.onSubmit(this.stringInput())
|
||||
.then(
|
||||
(value: any) => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`${this.openOptions.successMessage}: ${this.stringInput()}`
|
||||
);
|
||||
this.close();
|
||||
this.paneDeferred.resolve(value);
|
||||
},
|
||||
reason => {
|
||||
let error = reason;
|
||||
if (reason instanceof Error) {
|
||||
error = reason.message;
|
||||
} else if (typeof reason === "object") {
|
||||
error = JSON.stringify(reason);
|
||||
}
|
||||
|
||||
// If it's an AjaxError (AjaxObservable), add more error
|
||||
if (reason.response && reason.response.message) {
|
||||
error += `. ${reason.response.message}`;
|
||||
}
|
||||
|
||||
this.formErrors(this.openOptions.errorMessage);
|
||||
this.formErrorsDetails(`${this.openOptions.errorMessage}: ${error}`);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`${this.openOptions.errorMessage} ${this.stringInput()}: ${error}`
|
||||
);
|
||||
this.paneDeferred.reject(error);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(id);
|
||||
});
|
||||
}
|
||||
|
||||
public close() {
|
||||
super.close();
|
||||
this.resetData();
|
||||
this.resetFileInput();
|
||||
}
|
||||
|
||||
public openWithOptions<T>(options: ViewModels.StringInputPaneOpenOptions): Q.Promise<T> {
|
||||
this.openOptions = options;
|
||||
this.title(this.openOptions.paneTitle);
|
||||
if (this.openOptions.submitButtonLabel) {
|
||||
this.submitButtonLabel(this.openOptions.submitButtonLabel);
|
||||
}
|
||||
this.inputLabel(this.openOptions.inputLabel);
|
||||
this.stringInput(this.openOptions.defaultInput);
|
||||
|
||||
super.open();
|
||||
this.paneDeferred = Q.defer<T>();
|
||||
return this.paneDeferred.promise;
|
||||
}
|
||||
|
||||
private resetFileInput(): void {
|
||||
this.stringInput("");
|
||||
}
|
||||
}
|
||||
21
src/Explorer/Panes/SwitchDirectoryPane.html
Normal file
21
src/Explorer/Panes/SwitchDirectoryPane.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: close, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="switchdirectorypane">
|
||||
<!-- Switch Directory -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<!-- Switch Directory header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div class="closeImg" role="button" aria-label="Close pane" tabindex="0" data-bind="click: close">
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Switch Directory header - End -->
|
||||
|
||||
<!-- Switch Directory content - Start -->
|
||||
<div class="paneMainContent" data-bind="react: directoryComponentAdapter"></div>
|
||||
<!-- Switch Directory content - Start -->
|
||||
</div>
|
||||
<!-- Switch Directory -- End -->
|
||||
</div>
|
||||
</div>
|
||||
88
src/Explorer/Panes/SwitchDirectoryPane.ts
Normal file
88
src/Explorer/Panes/SwitchDirectoryPane.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as ko from "knockout";
|
||||
|
||||
import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants";
|
||||
import { DirectoryListProps } from "../Controls/Directory/DirectoryListComponent";
|
||||
import { DefaultDirectoryDropdownProps } from "../Controls/Directory/DefaultDirectoryDropdownComponent";
|
||||
import { DirectoryComponentAdapter } from "../Controls/Directory/DirectoryComponentAdapter";
|
||||
import SwitchDirectoryPaneTemplate from "./SwitchDirectoryPane.html";
|
||||
import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor";
|
||||
|
||||
class PaneComponent {
|
||||
constructor(data: any) {
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
|
||||
export class SwitchDirectoryPaneComponent {
|
||||
constructor() {
|
||||
return {
|
||||
viewModel: PaneComponent,
|
||||
template: SwitchDirectoryPaneTemplate
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SwitchDirectoryPane {
|
||||
public id: string;
|
||||
public firstFieldHasFocus: ko.Observable<boolean>;
|
||||
public title: ko.Observable<string>;
|
||||
public visible: ko.Observable<boolean>;
|
||||
|
||||
public directoryComponentAdapter: DirectoryComponentAdapter;
|
||||
|
||||
constructor(
|
||||
dropdownProps: ko.Observable<DefaultDirectoryDropdownProps>,
|
||||
listProps: ko.Observable<DirectoryListProps>
|
||||
) {
|
||||
this.id = "switchdirectorypane";
|
||||
this.title = ko.observable("Switch directory");
|
||||
this.visible = ko.observable(false);
|
||||
this.firstFieldHasFocus = ko.observable(false);
|
||||
this.resetData();
|
||||
this.directoryComponentAdapter = new DirectoryComponentAdapter(dropdownProps, listProps);
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.visible(true);
|
||||
this.firstFieldHasFocus(true);
|
||||
this.resizePane();
|
||||
TelemetryProcessor.trace(Action.ContextualPane, ActionModifiers.Open, {
|
||||
paneTitle: this.title()
|
||||
});
|
||||
|
||||
this.directoryComponentAdapter.forceRender();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.visible(false);
|
||||
this.resetData();
|
||||
this.directoryComponentAdapter.forceRender();
|
||||
}
|
||||
|
||||
public resetData() {
|
||||
this.firstFieldHasFocus(false);
|
||||
}
|
||||
|
||||
public onCloseKeyPress(source: any, event: KeyboardEvent): void {
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
public onPaneKeyDown(source: any, event: KeyboardEvent): boolean {
|
||||
if (event.key === "Escape") {
|
||||
this.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resizePane(): void {
|
||||
const paneElement: HTMLElement = document.getElementById(this.id);
|
||||
const headerElement: HTMLElement = document.getElementsByTagName("header")[0];
|
||||
const newPaneElementHeight = window.innerHeight - headerElement.offsetHeight;
|
||||
|
||||
paneElement.style.height = `${newPaneElementHeight}px`;
|
||||
}
|
||||
}
|
||||
146
src/Explorer/Panes/Tables/AddTableEntityPane.ts
Normal file
146
src/Explorer/Panes/Tables/AddTableEntityPane.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as ko from "knockout";
|
||||
import * as _ from "underscore";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { CassandraTableKey, CassandraAPIDataClient } from "../../Tables/TableDataClient";
|
||||
import * as DataTableUtilities from "../../Tables/DataTable/DataTableUtilities";
|
||||
import * as Entities from "../../Tables/Entities";
|
||||
import * as TableConstants from "../../Tables/Constants";
|
||||
import * as Utilities from "../../Tables/Utilities";
|
||||
import EntityPropertyViewModel from "./EntityPropertyViewModel";
|
||||
import TableEntityPane from "./TableEntityPane";
|
||||
|
||||
export default class AddTableEntityPane extends TableEntityPane implements ViewModels.AddTableEntityPane {
|
||||
private static _excludedFields: string[] = [TableConstants.EntityKeyNames.Timestamp];
|
||||
|
||||
private static _readonlyFields: string[] = [
|
||||
TableConstants.EntityKeyNames.PartitionKey,
|
||||
TableConstants.EntityKeyNames.RowKey,
|
||||
TableConstants.EntityKeyNames.Timestamp
|
||||
];
|
||||
|
||||
public enterRequiredValueLabel = "Enter identifier value."; // localize
|
||||
public enterValueLabel = "Enter value to keep property."; // localize
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.submitButtonText("Add Entity");
|
||||
this.container.isPreferredApiCassandra.subscribe(isCassandra => {
|
||||
if (isCassandra) {
|
||||
this.submitButtonText("Add Row");
|
||||
}
|
||||
});
|
||||
this.scrollId = ko.observable<string>("addEntityScroll");
|
||||
}
|
||||
|
||||
public submit() {
|
||||
if (!this.canApply()) {
|
||||
return;
|
||||
}
|
||||
let entity: Entities.ITableEntity = this.entityFromAttributes(this.displayedAttributes());
|
||||
this.container.tableDataClient
|
||||
.createDocument(this.tableViewModel.queryTablesTab.collection, entity)
|
||||
.then((newEntity: Entities.ITableEntity) => {
|
||||
this.tableViewModel.addEntityToCache(newEntity).then(() => {
|
||||
if (!this.tryInsertNewHeaders(this.tableViewModel, newEntity)) {
|
||||
this.tableViewModel.redrawTableThrottled();
|
||||
}
|
||||
});
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
public open() {
|
||||
var headers = this.tableViewModel.headers;
|
||||
if (DataTableUtilities.checkForDefaultHeader(headers)) {
|
||||
headers = [];
|
||||
if (this.container.isPreferredApiTable()) {
|
||||
headers = [TableConstants.EntityKeyNames.PartitionKey, TableConstants.EntityKeyNames.RowKey];
|
||||
}
|
||||
}
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
(<CassandraAPIDataClient>this.container.tableDataClient)
|
||||
.getTableSchema(this.tableViewModel.queryTablesTab.collection)
|
||||
.then((columns: CassandraTableKey[]) => {
|
||||
this.displayedAttributes(
|
||||
this.constructDisplayedAttributes(
|
||||
columns.map(col => col.property),
|
||||
Utilities.getDataTypesFromCassandraSchema(columns)
|
||||
)
|
||||
);
|
||||
this.updateIsActionEnabled();
|
||||
super.open();
|
||||
});
|
||||
} else {
|
||||
this.displayedAttributes(
|
||||
this.constructDisplayedAttributes(
|
||||
headers,
|
||||
Utilities.getDataTypesFromEntities(headers, this.tableViewModel.items())
|
||||
)
|
||||
);
|
||||
this.updateIsActionEnabled();
|
||||
super.open();
|
||||
}
|
||||
const focusElement = document.getElementById("addTableEntityValue");
|
||||
focusElement && focusElement.focus();
|
||||
}
|
||||
|
||||
private constructDisplayedAttributes(headers: string[], dataTypes: any): EntityPropertyViewModel[] {
|
||||
var displayedAttributes: EntityPropertyViewModel[] = [];
|
||||
headers &&
|
||||
headers.forEach((key: string) => {
|
||||
if (!_.contains<string>(AddTableEntityPane._excludedFields, key)) {
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
const cassandraKeys = this.tableViewModel.queryTablesTab.collection.cassandraKeys.partitionKeys
|
||||
.concat(this.tableViewModel.queryTablesTab.collection.cassandraKeys.clusteringKeys)
|
||||
.map(key => key.property);
|
||||
var isRequired: boolean = _.contains<string>(cassandraKeys, key);
|
||||
var editable: boolean = false;
|
||||
var placeholderLabel: string = isRequired ? this.enterRequiredValueLabel : this.enterValueLabel;
|
||||
var entityAttributeType: string = dataTypes[key] || TableConstants.CassandraType.Text; // Default to String if there is no type specified.
|
||||
// TODO figure out validation story for blob and Inet so we can allow adding/editing them
|
||||
const nonEditableType: boolean =
|
||||
entityAttributeType === TableConstants.CassandraType.Blob ||
|
||||
entityAttributeType === TableConstants.CassandraType.Inet;
|
||||
var entity: EntityPropertyViewModel = new EntityPropertyViewModel(
|
||||
this,
|
||||
key,
|
||||
entityAttributeType,
|
||||
"", // default to empty string
|
||||
/* namePlaceholder */ undefined,
|
||||
nonEditableType ? "Type is not editable via DataExplorer." : placeholderLabel,
|
||||
editable,
|
||||
/* default valid name */ true,
|
||||
/* default valid value */ true,
|
||||
/* required value */ isRequired,
|
||||
/* removable */ false,
|
||||
/* valueEditable */ !nonEditableType,
|
||||
/* ignoreEmptyValue */ true
|
||||
);
|
||||
} else {
|
||||
var isRequired: boolean = _.contains<string>(AddTableEntityPane.requiredFieldsForTablesAPI, key);
|
||||
var editable: boolean = !_.contains<string>(AddTableEntityPane._readonlyFields, key);
|
||||
var placeholderLabel: string = isRequired ? this.enterRequiredValueLabel : this.enterValueLabel;
|
||||
var entityAttributeType: string = dataTypes[key] || TableConstants.TableType.String; // Default to String if there is no type specified.
|
||||
var entity: EntityPropertyViewModel = new EntityPropertyViewModel(
|
||||
this,
|
||||
key,
|
||||
entityAttributeType,
|
||||
"", // default to empty string
|
||||
/* namePlaceholder */ undefined,
|
||||
placeholderLabel,
|
||||
editable,
|
||||
/* default valid name */ true,
|
||||
/* default valid value */ true,
|
||||
/* required value */ isRequired,
|
||||
/* removable */ editable,
|
||||
/* valueEditable */ true,
|
||||
/* ignoreEmptyValue */ true
|
||||
);
|
||||
}
|
||||
displayedAttributes.push(entity);
|
||||
}
|
||||
});
|
||||
|
||||
return displayedAttributes;
|
||||
}
|
||||
}
|
||||
228
src/Explorer/Panes/Tables/EditTableEntityPane.ts
Normal file
228
src/Explorer/Panes/Tables/EditTableEntityPane.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import * as ko from "knockout";
|
||||
import _ from "underscore";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { CassandraTableKey, CassandraAPIDataClient } from "../../Tables/TableDataClient";
|
||||
import * as Entities from "../../Tables/Entities";
|
||||
import TableEntityPane from "./TableEntityPane";
|
||||
import * as Utilities from "../../Tables/Utilities";
|
||||
import * as TableConstants from "../../Tables/Constants";
|
||||
import EntityPropertyViewModel from "./EntityPropertyViewModel";
|
||||
import * as TableEntityProcessor from "../../Tables/TableEntityProcessor";
|
||||
|
||||
export default class EditTableEntityPane extends TableEntityPane implements ViewModels.EditTableEntityPane {
|
||||
container: ViewModels.Explorer;
|
||||
visible: ko.Observable<boolean>;
|
||||
|
||||
public originEntity: Entities.ITableEntity;
|
||||
public originalNumberOfProperties: number;
|
||||
private originalDocument: any;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.submitButtonText("Update Entity");
|
||||
this.container.isPreferredApiCassandra.subscribe(isCassandra => {
|
||||
if (isCassandra) {
|
||||
this.submitButtonText("Update Row");
|
||||
}
|
||||
});
|
||||
this.scrollId = ko.observable<string>("editEntityScroll");
|
||||
}
|
||||
|
||||
public submit() {
|
||||
if (!this.canApply()) {
|
||||
return;
|
||||
}
|
||||
let entity: Entities.ITableEntity = this.updateEntity(this.displayedAttributes());
|
||||
this.container.tableDataClient
|
||||
.updateDocument(this.tableViewModel.queryTablesTab.collection, this.originalDocument, entity)
|
||||
.then((newEntity: Entities.ITableEntity) => {
|
||||
var numberOfProperties = 0;
|
||||
for (var property in newEntity) {
|
||||
if (
|
||||
property !== TableEntityProcessor.keyProperties.attachments &&
|
||||
property !== TableEntityProcessor.keyProperties.etag &&
|
||||
property !== TableEntityProcessor.keyProperties.resourceId &&
|
||||
property !== TableEntityProcessor.keyProperties.self &&
|
||||
(!this.container.isPreferredApiCassandra() || property !== TableConstants.EntityKeyNames.RowKey)
|
||||
) {
|
||||
numberOfProperties++;
|
||||
}
|
||||
}
|
||||
|
||||
var propertiesDelta = numberOfProperties - this.originalNumberOfProperties;
|
||||
|
||||
return this.tableViewModel
|
||||
.updateCachedEntity(newEntity)
|
||||
.then(() => {
|
||||
if (!this.tryInsertNewHeaders(this.tableViewModel, newEntity)) {
|
||||
this.tableViewModel.redrawTableThrottled();
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Selecting updated entity
|
||||
this.tableViewModel.selected.removeAll();
|
||||
this.tableViewModel.selected.push(newEntity);
|
||||
});
|
||||
});
|
||||
this.close();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.displayedAttributes(this.constructDisplayedAttributes(this.originEntity));
|
||||
if (this.container.isPreferredApiTable()) {
|
||||
this.originalDocument = TableEntityProcessor.convertEntitiesToDocuments(
|
||||
[<Entities.ITableEntityForTablesAPI>this.originEntity],
|
||||
this.tableViewModel.queryTablesTab.collection
|
||||
)[0]; // TODO change for Cassandra
|
||||
this.originalDocument.id = ko.observable<string>(this.originalDocument.id);
|
||||
} else {
|
||||
this.originalDocument = this.originEntity;
|
||||
}
|
||||
this.updateIsActionEnabled();
|
||||
super.open();
|
||||
}
|
||||
|
||||
private constructDisplayedAttributes(entity: Entities.ITableEntity): EntityPropertyViewModel[] {
|
||||
var displayedAttributes: EntityPropertyViewModel[] = [];
|
||||
const keys = Object.keys(entity);
|
||||
keys &&
|
||||
keys.forEach((key: string) => {
|
||||
if (
|
||||
key !== TableEntityProcessor.keyProperties.attachments &&
|
||||
key !== TableEntityProcessor.keyProperties.etag &&
|
||||
key !== TableEntityProcessor.keyProperties.resourceId &&
|
||||
key !== TableEntityProcessor.keyProperties.self &&
|
||||
(!this.container.isPreferredApiCassandra() || key !== TableConstants.EntityKeyNames.RowKey)
|
||||
) {
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
const cassandraKeys = this.tableViewModel.queryTablesTab.collection.cassandraKeys.partitionKeys
|
||||
.concat(this.tableViewModel.queryTablesTab.collection.cassandraKeys.clusteringKeys)
|
||||
.map(key => key.property);
|
||||
var entityAttribute: Entities.ITableEntityAttribute = entity[key];
|
||||
var entityAttributeType: string = entityAttribute.$;
|
||||
var displayValue: any = this.getPropertyDisplayValue(entity, key, entityAttributeType);
|
||||
var removable: boolean = false;
|
||||
// TODO figure out validation story for blob and Inet so we can allow adding/editing them
|
||||
const nonEditableType: boolean =
|
||||
entityAttributeType === TableConstants.CassandraType.Blob ||
|
||||
entityAttributeType === TableConstants.CassandraType.Inet;
|
||||
|
||||
displayedAttributes.push(
|
||||
new EntityPropertyViewModel(
|
||||
this,
|
||||
key,
|
||||
entityAttributeType,
|
||||
displayValue,
|
||||
/* namePlaceholder */ undefined,
|
||||
/* valuePlaceholder */ undefined,
|
||||
false,
|
||||
/* default valid name */ true,
|
||||
/* default valid value */ true,
|
||||
/* isRequired */ false,
|
||||
removable,
|
||||
/*value editable*/ !_.contains<string>(cassandraKeys, key) && !nonEditableType
|
||||
)
|
||||
);
|
||||
} else {
|
||||
var entityAttribute: Entities.ITableEntityAttribute = entity[key];
|
||||
var entityAttributeType: string = entityAttribute.$;
|
||||
var displayValue: any = this.getPropertyDisplayValue(entity, key, entityAttributeType);
|
||||
var editable: boolean = this.isAttributeEditable(key, entityAttributeType);
|
||||
// As per VSO:189935, Binary properties are read-only, we still want to be able to remove them.
|
||||
var removable: boolean = editable || entityAttributeType === TableConstants.TableType.Binary;
|
||||
|
||||
displayedAttributes.push(
|
||||
new EntityPropertyViewModel(
|
||||
this,
|
||||
key,
|
||||
entityAttributeType,
|
||||
displayValue,
|
||||
/* namePlaceholder */ undefined,
|
||||
/* valuePlaceholder */ undefined,
|
||||
editable,
|
||||
/* default valid name */ true,
|
||||
/* default valid value */ true,
|
||||
/* isRequired */ false,
|
||||
removable
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
(<CassandraAPIDataClient>this.container.tableDataClient)
|
||||
.getTableSchema(this.tableViewModel.queryTablesTab.collection)
|
||||
.then((properties: CassandraTableKey[]) => {
|
||||
properties &&
|
||||
properties.forEach(property => {
|
||||
if (!_.contains(keys, property.property)) {
|
||||
this.insertAttribute(property.property, property.type);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return displayedAttributes;
|
||||
}
|
||||
|
||||
private updateEntity(displayedAttributes: EntityPropertyViewModel[]): Entities.ITableEntity {
|
||||
var updatedEntity: any = {};
|
||||
displayedAttributes &&
|
||||
displayedAttributes.forEach((attribute: EntityPropertyViewModel) => {
|
||||
if (
|
||||
attribute.name() &&
|
||||
(!this.tableViewModel.queryTablesTab.container.isPreferredApiCassandra() || attribute.value() !== "")
|
||||
) {
|
||||
var value = attribute.getPropertyValue();
|
||||
var type = attribute.type();
|
||||
if (type === TableConstants.TableType.Int64) {
|
||||
value = Utilities.padLongWithZeros(value);
|
||||
}
|
||||
updatedEntity[attribute.name()] = {
|
||||
_: value,
|
||||
$: type
|
||||
};
|
||||
}
|
||||
});
|
||||
return updatedEntity;
|
||||
}
|
||||
|
||||
private isAttributeEditable(attributeName: string, entityAttributeType: string) {
|
||||
return !(
|
||||
attributeName === TableConstants.EntityKeyNames.PartitionKey ||
|
||||
attributeName === TableConstants.EntityKeyNames.RowKey ||
|
||||
attributeName === TableConstants.EntityKeyNames.Timestamp ||
|
||||
// As per VSO:189935, Making Binary properties read-only in Edit Entity dialog until we have a full story for it.
|
||||
entityAttributeType === TableConstants.TableType.Binary
|
||||
);
|
||||
}
|
||||
|
||||
private getPropertyDisplayValue(entity: Entities.ITableEntity, name: string, type: string): any {
|
||||
var attribute: Entities.ITableEntityAttribute = entity[name];
|
||||
var displayValue: any = attribute._;
|
||||
var isBinary: boolean = type === TableConstants.TableType.Binary;
|
||||
|
||||
// Showing the value in base64 for binary properties since that is what the Azure Storage Client Library expects.
|
||||
// This means that, even if the Azure Storage API returns a byte[] of binary content, it needs that same array
|
||||
// *base64 - encoded * as the value for the updated property or the whole update operation will fail.
|
||||
if (isBinary && displayValue && $.isArray(displayValue.data)) {
|
||||
var bytes: number[] = displayValue.data;
|
||||
displayValue = this.getBase64DisplayValue(bytes);
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
}
|
||||
|
||||
private getBase64DisplayValue(bytes: number[]): string {
|
||||
var displayValue: string = null;
|
||||
|
||||
try {
|
||||
var chars: string[] = bytes.map((byte: number) => String.fromCharCode(byte));
|
||||
var toEncode: string = chars.join("");
|
||||
displayValue = window.btoa(toEncode);
|
||||
} catch (error) {
|
||||
// Error
|
||||
}
|
||||
|
||||
return displayValue;
|
||||
}
|
||||
}
|
||||
164
src/Explorer/Panes/Tables/EntityPropertyViewModel.ts
Normal file
164
src/Explorer/Panes/Tables/EntityPropertyViewModel.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import * as ko from "knockout";
|
||||
|
||||
import * as DateTimeUtilities from "../../Tables/QueryBuilder/DateTimeUtilities";
|
||||
import * as EntityPropertyNameValidator from "./Validators/EntityPropertyNameValidator";
|
||||
import EntityPropertyValueValidator from "./Validators/EntityPropertyValueValidator";
|
||||
import * as Constants from "../../Tables/Constants";
|
||||
import * as Utilities from "../../Tables/Utilities";
|
||||
import TableEntityPane from "./TableEntityPane";
|
||||
|
||||
export interface IValidationResult {
|
||||
isInvalid: boolean;
|
||||
help: string;
|
||||
}
|
||||
|
||||
export interface IActionEnabledDialog {
|
||||
updateIsActionEnabled: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* View model for an entity proprety
|
||||
*/
|
||||
export default class EntityPropertyViewModel {
|
||||
/* Constants */
|
||||
public static noTooltip = "";
|
||||
// Maximum number of custom properties, see Azure Service Data Model
|
||||
// At https://msdn.microsoft.com/library/azure/dd179338.aspx
|
||||
public static maximumNumberOfProperties = 252;
|
||||
|
||||
// Labels
|
||||
public closeButtonLabel: string = "Close"; // localize
|
||||
|
||||
/* Observables */
|
||||
public name: ko.Observable<string>;
|
||||
public type: ko.Observable<string>;
|
||||
public value: ko.Observable<any>;
|
||||
public inputType: ko.Computed<string>;
|
||||
|
||||
public nameTooltip: ko.Observable<string>;
|
||||
public isInvalidName: ko.Observable<boolean>;
|
||||
|
||||
public valueTooltip: ko.Observable<string>;
|
||||
public isInvalidValue: ko.Observable<boolean>;
|
||||
|
||||
public namePlaceholder: ko.Observable<string>;
|
||||
public valuePlaceholder: ko.Observable<string>;
|
||||
|
||||
public hasFocus: ko.Observable<boolean>;
|
||||
public valueHasFocus: ko.Observable<boolean>;
|
||||
public isDateType: ko.Computed<boolean>;
|
||||
|
||||
public editable: boolean; // If a property's name or type is editable, these two are always the same regarding editability.
|
||||
public valueEditable: boolean; // If a property's value is editable, could be different from name or type.
|
||||
public removable: boolean; // If a property is removable, usually, PartitionKey, RowKey and TimeStamp (if applicable) are not removable.
|
||||
public isRequired: boolean; // If a property's value is required, used to differentiate the place holder label.
|
||||
public ignoreEmptyValue: boolean;
|
||||
|
||||
/* Members */
|
||||
private tableEntityPane: TableEntityPane;
|
||||
private _validator: EntityPropertyValueValidator;
|
||||
|
||||
constructor(
|
||||
tableEntityPane: TableEntityPane,
|
||||
name: string,
|
||||
type: string,
|
||||
value: any,
|
||||
namePlaceholder: string = "",
|
||||
valuePlaceholder: string = "",
|
||||
editable: boolean = false,
|
||||
defaultValidName: boolean = true,
|
||||
defaultValidValue: boolean = false,
|
||||
isRequired: boolean = false,
|
||||
removable: boolean = editable,
|
||||
valueEditable: boolean = editable,
|
||||
ignoreEmptyValue: boolean = false
|
||||
) {
|
||||
this.name = ko.observable<string>(name);
|
||||
this.type = ko.observable<string>(type);
|
||||
this.isDateType = ko.pureComputed<boolean>(() => this.type() === Constants.TableType.DateTime);
|
||||
if (this.isDateType()) {
|
||||
value = value ? DateTimeUtilities.getLocalDateTime(value) : value;
|
||||
}
|
||||
this.value = ko.observable(value);
|
||||
this.inputType = ko.pureComputed<string>(() => {
|
||||
if (!this.valueHasFocus() && !this.value() && this.isDateType()) {
|
||||
return Constants.InputType.Text;
|
||||
}
|
||||
return Utilities.getInputTypeFromDisplayedName(this.type());
|
||||
});
|
||||
|
||||
this.namePlaceholder = ko.observable<string>(namePlaceholder);
|
||||
this.valuePlaceholder = ko.observable<string>(valuePlaceholder);
|
||||
|
||||
this.editable = editable;
|
||||
this.isRequired = isRequired;
|
||||
this.removable = removable;
|
||||
this.valueEditable = valueEditable;
|
||||
|
||||
this._validator = new EntityPropertyValueValidator(isRequired);
|
||||
|
||||
this.tableEntityPane = tableEntityPane;
|
||||
|
||||
this.nameTooltip = ko.observable<string>(EntityPropertyViewModel.noTooltip);
|
||||
this.isInvalidName = ko.observable<boolean>(!defaultValidName);
|
||||
this.name.subscribe((name: string) => this.validateName(name));
|
||||
if (!defaultValidName) {
|
||||
this.validateName(name);
|
||||
}
|
||||
|
||||
this.valueTooltip = ko.observable<string>(EntityPropertyViewModel.noTooltip);
|
||||
this.isInvalidValue = ko.observable<boolean>(!defaultValidValue);
|
||||
this.value.subscribe((value: string) => this.validateValue(value, this.type()));
|
||||
if (!defaultValidValue) {
|
||||
this.validateValue(value, type);
|
||||
}
|
||||
|
||||
this.type.subscribe((type: string) => this.validateValue(this.value(), type));
|
||||
|
||||
this.hasFocus = ko.observable<boolean>(false);
|
||||
this.valueHasFocus = ko.observable<boolean>(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the Javascript value of the entity property based on its EDM type.
|
||||
*/
|
||||
public getPropertyValue(): any {
|
||||
var value: string = this.value();
|
||||
if (this.type() === Constants.TableType.DateTime) {
|
||||
value = DateTimeUtilities.getUTCDateTime(value);
|
||||
}
|
||||
return this._validator.parseValue(value, this.type());
|
||||
}
|
||||
|
||||
private validateName(name: string): void {
|
||||
var result: IValidationResult = this.isInvalidNameInput(name);
|
||||
|
||||
this.isInvalidName(result.isInvalid);
|
||||
this.nameTooltip(result.help);
|
||||
this.namePlaceholder(result.help);
|
||||
this.tableEntityPane.updateIsActionEnabled();
|
||||
}
|
||||
|
||||
private validateValue(value: string, type: string): void {
|
||||
var result: IValidationResult = this.isInvalidValueInput(value, type);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isInvalidValue(result.isInvalid);
|
||||
this.valueTooltip(result.help);
|
||||
this.valuePlaceholder(result.help);
|
||||
this.tableEntityPane.updateIsActionEnabled();
|
||||
}
|
||||
|
||||
private isInvalidNameInput(name: string): IValidationResult {
|
||||
return EntityPropertyNameValidator.validate(name);
|
||||
}
|
||||
|
||||
private isInvalidValueInput(value: string, type: string): IValidationResult {
|
||||
if (this.ignoreEmptyValue && this.value() === "") {
|
||||
return { isInvalid: false, help: "" };
|
||||
}
|
||||
return this._validator.validate(value, type);
|
||||
}
|
||||
}
|
||||
174
src/Explorer/Panes/Tables/QuerySelectPane.ts
Normal file
174
src/Explorer/Panes/Tables/QuerySelectPane.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as ko from "knockout";
|
||||
import _ from "underscore";
|
||||
import * as Constants from "../../Tables/Constants";
|
||||
import QueryViewModel from "../../Tables/QueryBuilder/QueryViewModel";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "../ContextualPaneBase";
|
||||
|
||||
export interface ISelectColumn {
|
||||
columnName: ko.Observable<string>;
|
||||
selected: ko.Observable<boolean>;
|
||||
editable: ko.Observable<boolean>;
|
||||
}
|
||||
|
||||
export class QuerySelectPane extends ContextualPaneBase implements ViewModels.QuerySelectPane {
|
||||
public titleLabel: string = "Select Columns";
|
||||
public instructionLabel: string = "Select the columns that you want to query.";
|
||||
public availableColumnsTableQueryLabel: string = "Available Columns";
|
||||
public noColumnSelectedWarning: string = "At least one column should be selected.";
|
||||
|
||||
public columnOptions: ko.ObservableArray<ISelectColumn>;
|
||||
public anyColumnSelected: ko.Computed<boolean>;
|
||||
public canSelectAll: ko.Computed<boolean>;
|
||||
public allSelected: ko.Computed<boolean>;
|
||||
|
||||
private selectedColumnOption: ISelectColumn = null;
|
||||
|
||||
public queryViewModel: QueryViewModel;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
|
||||
this.columnOptions = ko.observableArray<ISelectColumn>();
|
||||
this.anyColumnSelected = ko.computed<boolean>(() => {
|
||||
return _.some(this.columnOptions(), (value: ISelectColumn) => {
|
||||
return value.selected();
|
||||
});
|
||||
});
|
||||
|
||||
this.canSelectAll = ko.computed<boolean>(() => {
|
||||
return _.some(this.columnOptions(), (value: ISelectColumn) => {
|
||||
return !value.selected();
|
||||
});
|
||||
});
|
||||
|
||||
this.allSelected = ko.pureComputed<boolean>({
|
||||
read: () => {
|
||||
return !this.canSelectAll();
|
||||
},
|
||||
write: value => {
|
||||
if (value) {
|
||||
this.selectAll();
|
||||
} else {
|
||||
this.clearAll();
|
||||
}
|
||||
},
|
||||
owner: this
|
||||
});
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.queryViewModel.selectText(this.getParameters());
|
||||
this.queryViewModel.getSelectMessage();
|
||||
this.close();
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.setTableColumns(this.queryViewModel.columnOptions());
|
||||
this.setDisplayedColumns(this.queryViewModel.selectText(), this.columnOptions());
|
||||
super.open();
|
||||
}
|
||||
|
||||
private getParameters(): string[] {
|
||||
if (this.canSelectAll() === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
var selectedColumns = this.columnOptions().filter((value: ISelectColumn) => value.selected() === true);
|
||||
|
||||
var columns: string[] = selectedColumns.map((value: ISelectColumn) => {
|
||||
var name: string = value.columnName();
|
||||
return name;
|
||||
});
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
public setTableColumns(columnNames: string[]): void {
|
||||
var columns: ISelectColumn[] = columnNames.map((value: string) => {
|
||||
var columnOption: ISelectColumn = {
|
||||
columnName: ko.observable<string>(value),
|
||||
selected: ko.observable<boolean>(true),
|
||||
editable: ko.observable<boolean>(this.isEntityEditable(value))
|
||||
};
|
||||
return columnOption;
|
||||
});
|
||||
|
||||
this.columnOptions(columns);
|
||||
}
|
||||
|
||||
public setDisplayedColumns(querySelect: string[], columns: ISelectColumn[]): void {
|
||||
if (querySelect == null || _.isEmpty(querySelect)) {
|
||||
return;
|
||||
}
|
||||
this.setSelected(querySelect, columns);
|
||||
}
|
||||
|
||||
private setSelected(querySelect: string[], columns: ISelectColumn[]): void {
|
||||
this.clearAll();
|
||||
querySelect &&
|
||||
querySelect.forEach((value: string) => {
|
||||
for (var i = 0; i < columns.length; i++) {
|
||||
if (value === columns[i].columnName()) {
|
||||
columns[i].selected(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public availableColumnsCheckboxClick(): boolean {
|
||||
if (this.canSelectAll()) {
|
||||
return this.selectAll();
|
||||
} else {
|
||||
return this.clearAll();
|
||||
}
|
||||
}
|
||||
|
||||
public selectAll(): boolean {
|
||||
const columnOptions = this.columnOptions && this.columnOptions();
|
||||
columnOptions &&
|
||||
columnOptions.forEach((value: ISelectColumn) => {
|
||||
value.selected(true);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public clearAll(): boolean {
|
||||
const columnOptions = this.columnOptions && this.columnOptions();
|
||||
columnOptions &&
|
||||
columnOptions.forEach((column: ISelectColumn) => {
|
||||
if (this.isEntityEditable(column.columnName())) {
|
||||
column.selected(false);
|
||||
} else {
|
||||
column.selected(true);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
public handleClick = (data: ISelectColumn, event: KeyboardEvent): boolean => {
|
||||
this.selectTargetItem($(event.currentTarget), data);
|
||||
return true;
|
||||
};
|
||||
|
||||
private selectTargetItem($target: JQuery, targetColumn: ISelectColumn): void {
|
||||
this.selectedColumnOption = targetColumn;
|
||||
|
||||
$(".list-item.selected").removeClass("selected");
|
||||
$target.addClass("selected");
|
||||
}
|
||||
|
||||
private isEntityEditable(name: string) {
|
||||
if (this.queryViewModel.queryTablesTab.container.isPreferredApiCassandra()) {
|
||||
const cassandraKeys = this.queryViewModel.queryTablesTab.collection.cassandraKeys.partitionKeys
|
||||
.concat(this.queryViewModel.queryTablesTab.collection.cassandraKeys.clusteringKeys)
|
||||
.map(key => key.property);
|
||||
return !_.contains<string>(cassandraKeys, name);
|
||||
}
|
||||
return !(
|
||||
name === Constants.EntityKeyNames.PartitionKey ||
|
||||
name === Constants.EntityKeyNames.RowKey ||
|
||||
name === Constants.EntityKeyNames.Timestamp
|
||||
);
|
||||
}
|
||||
}
|
||||
190
src/Explorer/Panes/Tables/TableAddEntityPane.html
Normal file
190
src/Explorer/Panes/Tables/TableAddEntityPane.html
Normal file
@@ -0,0 +1,190 @@
|
||||
<div data-bind="visible: visible">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" style="width:700px;" id="addtableentitypane">
|
||||
<!-- Add table entity form - Start -->
|
||||
<div
|
||||
class="contextual-pane-in"
|
||||
data-bind="
|
||||
visible: !isEditing()"
|
||||
>
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Add table entity header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span role="heading" aria-level="2" data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keydown: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add table entity header - End -->
|
||||
<div class="tableParamContent paneContentContainer">
|
||||
<div class="entity-table">
|
||||
<div class="entity-table-row">
|
||||
<div class="entity-table-cell entity-table-property-header" data-bind="text: attributeNameLabel"></div>
|
||||
<div class="entity-table-cell entity-table-type-header" data-bind="text: dataTypeLabel"></div>
|
||||
<div class="entity-table-cell entity-table-value-header" data-bind="text: attributeValueLabel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-table-scroll-box" id="addEntityScroll">
|
||||
<div class="entity-table" data-bind="foreach: displayedAttributes">
|
||||
<div class="entity-table-row">
|
||||
<div class="entity-table-cell entity-table-property-column">
|
||||
<input
|
||||
type="text"
|
||||
class="entity-table-field entity-table-property-column"
|
||||
required
|
||||
data-bind="
|
||||
textInput: name,
|
||||
attr: { title: nameTooltip, placeholder: namePlaceholder, 'aria-label': 'property name' },
|
||||
css: { 'invalid-field': isInvalidName },
|
||||
readOnly: !editable,
|
||||
hasFocus: hasFocus"
|
||||
/>
|
||||
</div>
|
||||
<div class="entity-table-cell entity-table-type-column">
|
||||
<select
|
||||
class="entity-table-field"
|
||||
data-bind="
|
||||
options: $parent.edmTypes,
|
||||
optionsAfterRender: $parent.setOptionDisable,
|
||||
value: type,
|
||||
attr: { 'aria-label': 'type' },
|
||||
enable: editable,
|
||||
readOnly: !editable"
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<!-- ko ifnot: isDateType -->
|
||||
<div class="entity-table-cell entity-table-value-column">
|
||||
<input
|
||||
class="entity-table-field"
|
||||
id="addTableEntityValue"
|
||||
step="1"
|
||||
data-bind="
|
||||
textInput: value,
|
||||
attr: { title: valueTooltip, placeholder: valuePlaceholder, type: inputType, 'aria-label': 'value' },
|
||||
css: { 'invalid-field': isInvalidValue },
|
||||
readOnly: !valueEditable"
|
||||
/>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: isDateType -->
|
||||
<div class="entity-table-cell entity-table-value-column">
|
||||
<input
|
||||
class="entity-table-field"
|
||||
step="1"
|
||||
data-bind="
|
||||
value: value,
|
||||
attr: { title: valueTooltip, placeholder: valuePlaceholder, type: inputType },
|
||||
css: { 'invalid-field': isInvalidValue },
|
||||
readOnly: !valueEditable,
|
||||
hasFocus: valueHasFocus"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<div class="entity-table-cell entity-table-action-column" data-bind="if: removable || valueEditable">
|
||||
<span
|
||||
class="entity-Edit-Cancel"
|
||||
title="Edit property"
|
||||
role="button"
|
||||
aria-label="Edit property"
|
||||
tabindex="0"
|
||||
data-bind="click: $parent.editAttribute.bind($data, $index()), visible: valueEditable, event: { keydown: $parent.onEditPropertyKeyDown.bind($data, $index()) }"
|
||||
>
|
||||
<img class="entity-Editor-Cancel-Img" src="/Edit_entity.svg" alt="Edit" />
|
||||
</span>
|
||||
<span
|
||||
class="entity-Edit-Cancel"
|
||||
title="Delete property"
|
||||
role="button"
|
||||
aria-label="Delete property"
|
||||
tabindex="0"
|
||||
data-bind="click: $parent.removeAttribute.bind($data, $index()), visible: removable, event: { keydown: $parent.onDeletePropertyKeyDown.bind($data, $index()) }"
|
||||
>
|
||||
<img class="entity-Editor-Cancel-Img" src="/delete.svg" alt="Cancel" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-table addProperty">
|
||||
<div class="entity-table-row">
|
||||
<div class="entity-table-cell">
|
||||
<span
|
||||
class="commandButton"
|
||||
id="addProperty"
|
||||
role="button"
|
||||
aria-label="Add property"
|
||||
tabindex="0"
|
||||
data-bind="visible: canAdd, click: insertAttribute, event: { keydown: onAddPropertyKeyDown }"
|
||||
autofocus
|
||||
>
|
||||
<img class="addPropertyImg" src="/Add-property.svg" alt="Insert attribute" />
|
||||
<span data-bind="text: addButtonLabel"> </span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input
|
||||
type="submit"
|
||||
class="btncreatecoll1"
|
||||
data-bind="value: submitButtonText, event: { keydown: onSubmitKeyPress }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Add table entity form - End -->
|
||||
<!-- Add table entity editor - Start -->
|
||||
<div id="editor-panel-addEntity" data-bind="visible: isEditing()" style="display: none">
|
||||
<div data-bind="with: editingProperty()">
|
||||
<!-- Add table entity editor header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span
|
||||
class="backBtn"
|
||||
aria-label="Back"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: $parent.finishEditingAttribute, event: { keydown: $parent.onBackButtonKeyDown }"
|
||||
>
|
||||
<img src="/RevertBack.svg" alt="BackIcon" />
|
||||
</span>
|
||||
<span class="edit-value-text" data-bind="text: name"></span>
|
||||
</div>
|
||||
<!-- Add table entity editor header - End -->
|
||||
<div class="seconddivbg paddingspan2">
|
||||
<textarea
|
||||
class="entity-editor-expanded"
|
||||
id="textAreaEditProperty"
|
||||
tabindex="0"
|
||||
rows="21"
|
||||
data-bind="value: value, attr: { 'aria-label': name }"
|
||||
style="width:95%"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add table entity editor - End -->
|
||||
</div>
|
||||
</div>
|
||||
78
src/Explorer/Panes/Tables/TableColumnOptionsPane.html
Normal file
78
src/Explorer/Panes/Tables/TableColumnOptionsPane.html
Normal file
@@ -0,0 +1,78 @@
|
||||
<div data-bind="visible: visible">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="tablecolumnoptionspane">
|
||||
<!-- Table Column Options form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Table Column Options header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
Column Options
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel"
|
||||
>
|
||||
<img src="../../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Table Column Options header - End -->
|
||||
<div class="paneMainContent paneContentContainer">
|
||||
<div><span>Choose the columns and the order in which you want to display them in the table.</span></div>
|
||||
<div class="column-options">
|
||||
<div class="columns-border">
|
||||
<input class="all-select-check" type="checkbox" data-bind="checked: allSelected" />
|
||||
<label
|
||||
style="font-weight:700;"
|
||||
id="availableColumnsLabel"
|
||||
data-bind="text: availableColumnsLabel"
|
||||
></label>
|
||||
<span class="column-arrows-svg" data-bind="click: moveDown, enable: canMoveDown">
|
||||
<img class="column-opt-arrow-Img" src="/Down.svg" alt="Move down" />
|
||||
</span>
|
||||
<span class="column-arrows-svg" data-bind="click: moveUp, enable: canMoveUp">
|
||||
<img class="column-opt-arrow-Img" src="/Up.svg" alt="Move up" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<section>
|
||||
<ul data-bind="foreach: columnOptions" aria-labelledby="availableColumnsLabel" tabindex="0">
|
||||
<li
|
||||
class="list-item columns-border"
|
||||
data-bind="attr: { title: columnName }, click: $parent.handleClick "
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
for="columnName"
|
||||
data-bind="attr: { title: columnName, 'aria-selected': (selected()? 'true': 'false') }, checked: selected"
|
||||
/>
|
||||
<label id="columnName" data-bind="text: columnName"></label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-label" data-bind="style: { visibility: anyColumnSelected() ? 'hidden': 'visible' }">
|
||||
<label class="warning" role="alert" aria-atomic="true" data-bind="text: noColumnSelectedWarning"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="OK" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Table Column Options form - End -->
|
||||
</div>
|
||||
</div>
|
||||
195
src/Explorer/Panes/Tables/TableColumnOptionsPane.ts
Normal file
195
src/Explorer/Panes/Tables/TableColumnOptionsPane.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as ko from "knockout";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import * as DataTableOperations from "../../Tables/DataTable/DataTableOperations";
|
||||
import TableEntityListViewModel from "../../Tables/DataTable/TableEntityListViewModel";
|
||||
import { ContextualPaneBase } from "../ContextualPaneBase";
|
||||
import _ from "underscore";
|
||||
|
||||
/**
|
||||
* Represents an item shown in the available columns.
|
||||
* columnName: the name of the column.
|
||||
* selected: indicate whether user wants to display the column in the table.
|
||||
* order: the order in the initial table. E.g.,
|
||||
* Order array of initial table: I = [0, 1, 2, 3, 4, 5, 6, 7, 8] <----> {prop0, prop1, prop2, prop3, prop4, prop5, prop6, prop7, prop8}
|
||||
* Order array of current table: C = [0, 1, 2, 6, 7, 3, 4, 5, 8] <----> {prop0, prop1, prop2, prop6, prop7, prop3, prop4, prop5, prop8}
|
||||
* if order = 6, then this column will be the one with column name prop6
|
||||
* index: index in the observable array, this used for selection and moving up/down.
|
||||
*/
|
||||
interface IColumnOption {
|
||||
columnName: ko.Observable<string>;
|
||||
selected: ko.Observable<boolean>;
|
||||
order: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface IColumnSetting {
|
||||
columnNames: string[];
|
||||
visible?: boolean[];
|
||||
order?: number[];
|
||||
}
|
||||
|
||||
export class TableColumnOptionsPane extends ContextualPaneBase implements ViewModels.TableColumnOptionsPane {
|
||||
public titleLabel: string = "Column Options";
|
||||
public instructionLabel: string = "Choose the columns and the order in which you want to display them in the table.";
|
||||
public availableColumnsLabel: string = "Available Columns";
|
||||
public moveUpLabel: string = "Move Up";
|
||||
public moveDownLabel: string = "Move Down";
|
||||
public noColumnSelectedWarning: string = "At least one column should be selected.";
|
||||
|
||||
public columnOptions: ko.ObservableArray<IColumnOption>;
|
||||
public allSelected: ko.Computed<boolean>;
|
||||
public anyColumnSelected: ko.Computed<boolean>;
|
||||
public canSelectAll: ko.Computed<boolean>;
|
||||
public canMoveUp: ko.Observable<boolean>;
|
||||
public canMoveDown: ko.Observable<boolean>;
|
||||
|
||||
public tableViewModel: TableEntityListViewModel;
|
||||
public parameters: IColumnSetting;
|
||||
|
||||
private selectedColumnOption: IColumnOption = null;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
|
||||
this.columnOptions = ko.observableArray<IColumnOption>();
|
||||
this.anyColumnSelected = ko.computed<boolean>(() => {
|
||||
return _.some(this.columnOptions(), (value: IColumnOption) => {
|
||||
return value.selected();
|
||||
});
|
||||
});
|
||||
|
||||
this.canSelectAll = ko.computed<boolean>(() => {
|
||||
return _.some(this.columnOptions(), (value: IColumnOption) => {
|
||||
return !value.selected();
|
||||
});
|
||||
});
|
||||
|
||||
this.canMoveUp = ko.observable<boolean>(false);
|
||||
this.canMoveDown = ko.observable<boolean>(false);
|
||||
|
||||
this.allSelected = ko.pureComputed<boolean>({
|
||||
read: () => {
|
||||
return !this.canSelectAll();
|
||||
},
|
||||
write: value => {
|
||||
if (value) {
|
||||
this.selectAll();
|
||||
} else {
|
||||
this.clearAll();
|
||||
}
|
||||
},
|
||||
owner: this
|
||||
});
|
||||
}
|
||||
|
||||
public submit() {
|
||||
var newColumnSetting = this.getParameters();
|
||||
DataTableOperations.reorderColumns(this.tableViewModel.table, newColumnSetting.order).then(() => {
|
||||
DataTableOperations.filterColumns(this.tableViewModel.table, newColumnSetting.visible);
|
||||
this.visible(false);
|
||||
});
|
||||
}
|
||||
public open() {
|
||||
this.setDisplayedColumns(this.parameters.columnNames, this.parameters.order, this.parameters.visible);
|
||||
super.open();
|
||||
}
|
||||
|
||||
private getParameters(): IColumnSetting {
|
||||
var newColumnSettings: IColumnSetting = <IColumnSetting>{
|
||||
columnNames: [],
|
||||
order: [],
|
||||
visible: []
|
||||
};
|
||||
this.columnOptions().map((value: IColumnOption) => {
|
||||
newColumnSettings.columnNames.push(value.columnName());
|
||||
newColumnSettings.order.push(value.order);
|
||||
newColumnSettings.visible.push(value.selected());
|
||||
});
|
||||
return newColumnSettings;
|
||||
}
|
||||
|
||||
public setDisplayedColumns(columnNames: string[], order: number[], visible: boolean[]): void {
|
||||
var options: IColumnOption[] = order.map((value: number, index: number) => {
|
||||
var columnOption: IColumnOption = {
|
||||
columnName: ko.observable<string>(columnNames[index]),
|
||||
order: value,
|
||||
selected: ko.observable<boolean>(visible[index]),
|
||||
index: index
|
||||
};
|
||||
return columnOption;
|
||||
});
|
||||
this.columnOptions(options);
|
||||
}
|
||||
|
||||
public selectAll(): void {
|
||||
const columnOptions = this.columnOptions && this.columnOptions();
|
||||
columnOptions &&
|
||||
columnOptions.forEach((value: IColumnOption) => {
|
||||
value.selected(true);
|
||||
});
|
||||
}
|
||||
|
||||
public clearAll(): void {
|
||||
const columnOptions = this.columnOptions && this.columnOptions();
|
||||
columnOptions &&
|
||||
columnOptions.forEach((value: IColumnOption) => {
|
||||
value.selected(false);
|
||||
});
|
||||
|
||||
if (columnOptions && columnOptions.length > 0) {
|
||||
columnOptions[0].selected(true);
|
||||
}
|
||||
}
|
||||
|
||||
public moveUp(): void {
|
||||
if (this.selectedColumnOption) {
|
||||
var currentSelectedIndex: number = this.selectedColumnOption.index;
|
||||
var swapTargetIndex: number = currentSelectedIndex - 1;
|
||||
//Debug.assert(currentSelectedIndex > 0);
|
||||
|
||||
this.swapColumnOption(this.columnOptions(), swapTargetIndex, currentSelectedIndex);
|
||||
this.selectTargetItem($(`div.column-options li:eq(${swapTargetIndex})`), this.columnOptions()[swapTargetIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
public moveDown(): void {
|
||||
if (this.selectedColumnOption) {
|
||||
var currentSelectedIndex: number = this.selectedColumnOption.index;
|
||||
var swapTargetIndex: number = currentSelectedIndex + 1;
|
||||
//Debug.assert(currentSelectedIndex < (this.columnOptions().length - 1));
|
||||
|
||||
this.swapColumnOption(this.columnOptions(), swapTargetIndex, currentSelectedIndex);
|
||||
this.selectTargetItem($(`div.column-options li:eq(${swapTargetIndex})`), this.columnOptions()[swapTargetIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
public handleClick = (data: IColumnOption, event: KeyboardEvent): boolean => {
|
||||
this.selectTargetItem($(event.currentTarget), data);
|
||||
return true;
|
||||
};
|
||||
|
||||
private selectTargetItem($target: JQuery, targetColumn: IColumnOption): void {
|
||||
this.selectedColumnOption = targetColumn;
|
||||
|
||||
this.canMoveUp(targetColumn.index !== 0);
|
||||
this.canMoveDown(targetColumn.index !== this.columnOptions().length - 1);
|
||||
|
||||
$(".list-item.selected").removeClass("selected");
|
||||
$target.addClass("selected");
|
||||
}
|
||||
|
||||
private swapColumnOption(options: IColumnOption[], indexA: number, indexB: number): void {
|
||||
var tempColumnName: string = options[indexA].columnName();
|
||||
var tempSelected: boolean = options[indexA].selected();
|
||||
var tempOrder: number = options[indexA].order;
|
||||
|
||||
options[indexA].columnName(options[indexB].columnName());
|
||||
options[indexB].columnName(tempColumnName);
|
||||
|
||||
options[indexA].selected(options[indexB].selected());
|
||||
options[indexB].selected(tempSelected);
|
||||
|
||||
options[indexA].order = options[indexB].order;
|
||||
options[indexB].order = tempOrder;
|
||||
}
|
||||
}
|
||||
188
src/Explorer/Panes/Tables/TableEditEntityPane.html
Normal file
188
src/Explorer/Panes/Tables/TableEditEntityPane.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<div data-bind="visible: visible">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" style="width:700px;" id="edittableentitypane">
|
||||
<!-- Edit table entity form - Start -->
|
||||
<div
|
||||
class="contextual-pane-in"
|
||||
data-bind="
|
||||
visible: !isEditing()"
|
||||
>
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Edit table entity header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keydown: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit table entity header - End -->
|
||||
<div class="tableParamContent paneContentContainer">
|
||||
<div class="entity-table">
|
||||
<div class="entity-table-row">
|
||||
<div class="entity-table-cell entity-table-property-header" data-bind="text: attributeNameLabel"></div>
|
||||
<div class="entity-table-cell entity-table-type-header" data-bind="text: dataTypeLabel"></div>
|
||||
<div class="entity-table-cell entity-table-value-header" data-bind="text: attributeValueLabel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-table-scroll-box" id="editEntityScroll">
|
||||
<div class="entity-table" data-bind="foreach: displayedAttributes">
|
||||
<div class="entity-table-row">
|
||||
<div class="entity-table-cell entity-table-property-column">
|
||||
<input
|
||||
type="text"
|
||||
class="entity-table-field entity-table-property-column"
|
||||
required
|
||||
data-bind="
|
||||
textInput: name,
|
||||
attr: { title: nameTooltip, placeholder: namePlaceholder, 'aria-label': 'property name' },
|
||||
css: { 'invalid-field': isInvalidName },
|
||||
readOnly: !editable,
|
||||
hasFocus: hasFocus"
|
||||
/>
|
||||
</div>
|
||||
<div class="entity-table-cell entity-table-type-column">
|
||||
<select
|
||||
class="entity-table-field"
|
||||
data-bind="
|
||||
options: $parent.edmTypes,
|
||||
optionsAfterRender: $parent.setOptionDisable,
|
||||
value: type,
|
||||
attr: { 'aria-label': 'type' },
|
||||
enable: editable,
|
||||
readOnly: !editable"
|
||||
>
|
||||
</select>
|
||||
</div>
|
||||
<!-- ko ifnot: isDateType -->
|
||||
<div class="entity-table-cell entity-table-value-column">
|
||||
<input
|
||||
class="entity-table-field"
|
||||
step="1"
|
||||
data-bind="
|
||||
textInput: value,
|
||||
attr: { title: valueTooltip, placeholder: valuePlaceholder, type: inputType, 'aria-label': 'value' },
|
||||
css: { 'invalid-field': isInvalidValue },
|
||||
readOnly: !valueEditable"
|
||||
/>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<!-- ko if: isDateType -->
|
||||
<div class="entity-table-cell entity-table-value-column">
|
||||
<input
|
||||
class="entity-table-field"
|
||||
step="1"
|
||||
data-bind="
|
||||
value: value,
|
||||
attr: { title: valueTooltip, placeholder: valuePlaceholder, type: inputType, 'aria-label': 'value' },
|
||||
css: { 'invalid-field': isInvalidValue },
|
||||
readOnly: !valueEditable,
|
||||
hasFocus: valueHasFocus"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<!-- /ko -->
|
||||
<div class="entity-table-cell entity-table-action-column" data-bind="if: removable || valueEditable">
|
||||
<span
|
||||
class="entity-Edit-Cancel"
|
||||
role="button"
|
||||
aria-label="Edit property"
|
||||
tabindex="0"
|
||||
data-bind="click: $parent.editAttribute.bind($data, $index()), visible: valueEditable, event: { keydown: $parent.onEditPropertyKeyDown.bind($data, $index()) }"
|
||||
title="Edit property"
|
||||
>
|
||||
<img class="entity-Editor-Cancel-Img" src="/Edit_entity.svg" alt="Edit attribute" />
|
||||
</span>
|
||||
<span
|
||||
class="entity-Edit-Cancel"
|
||||
role="button"
|
||||
aria-label="Delete property"
|
||||
tabindex="0"
|
||||
data-bind="click: $parent.removeAttribute.bind($data, $index()), visible: removable, event: { keydown: $parent.onDeletePropertyKeyDown.bind($data, $index()) }"
|
||||
title="Delete property"
|
||||
>
|
||||
<img class="entity-Editor-Cancel-Img" src="/delete.svg" alt="Remove attribute" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-table addProperty">
|
||||
<div class="entity-table-row">
|
||||
<div class="entity-table-cell">
|
||||
<span
|
||||
class="commandButton"
|
||||
role="button"
|
||||
aria-label="Add property"
|
||||
tabindex="0"
|
||||
data-bind="visible: canAdd, click: insertAttribute, event: { keydown: onAddPropertyKeyDown }"
|
||||
autofocus
|
||||
>
|
||||
<img class="addPropertyImg" src="/Add-property.svg" alt="Add attribute" />
|
||||
<span data-bind="text: addButtonLabel"> </span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input
|
||||
type="submit"
|
||||
value="Update Entity"
|
||||
class="btncreatecoll1"
|
||||
data-bind="value: submitButtonText, event: { keydown: onSubmitKeyPress }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Edit table entity form - End -->
|
||||
<!-- Edit table entity editor - Start -->
|
||||
<div id="editor-panel-editEntity" data-bind="visible: isEditing()" style="display: none">
|
||||
<div data-bind="with: editingProperty()">
|
||||
<!-- Edit table entity editor header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span
|
||||
class="backBtn"
|
||||
aria-label="Back"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: $parent.finishEditingAttribute, event: { keydown: $parent.onBackButtonKeyDown }"
|
||||
>
|
||||
<img src="/RevertBack.svg" alt="BackIcon" />
|
||||
</span>
|
||||
<span class="edit-value-text" data-bind="text: name"></span>
|
||||
</div>
|
||||
<!-- Edit table entity editor header - End -->
|
||||
<div class="seconddivbg paddingspan2">
|
||||
<textarea
|
||||
class="entity-editor-expanded"
|
||||
id="editor-area"
|
||||
tabindex="0"
|
||||
rows="21"
|
||||
data-bind="value: value, attr: { 'aria-label': name }"
|
||||
autofocus
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit table entity editor - End -->
|
||||
</div>
|
||||
</div>
|
||||
281
src/Explorer/Panes/Tables/TableEntityPane.ts
Normal file
281
src/Explorer/Panes/Tables/TableEntityPane.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import * as ko from "knockout";
|
||||
import _ from "underscore";
|
||||
import * as DataTableUtilities from "../../Tables/DataTable/DataTableUtilities";
|
||||
import * as Entities from "../../Tables/Entities";
|
||||
import EntityPropertyViewModel from "./EntityPropertyViewModel";
|
||||
import * as TableConstants from "../../Tables/Constants";
|
||||
import TableEntityListViewModel from "../../Tables/DataTable/TableEntityListViewModel";
|
||||
import * as TableEntityProcessor from "../../Tables/TableEntityProcessor";
|
||||
import * as Utilities from "../../Tables/Utilities";
|
||||
import * as ViewModels from "../../../Contracts/ViewModels";
|
||||
import { KeyCodes } from "../../../Common/Constants";
|
||||
import { ContextualPaneBase } from "../ContextualPaneBase";
|
||||
|
||||
// Class with variables and functions that are common to both adding and editing entities
|
||||
export default abstract class TableEntityPane extends ContextualPaneBase {
|
||||
protected static requiredFieldsForTablesAPI: string[] = [
|
||||
TableConstants.EntityKeyNames.PartitionKey,
|
||||
TableConstants.EntityKeyNames.RowKey
|
||||
];
|
||||
|
||||
/* Labels */
|
||||
public attributeNameLabel = "Property Name"; // localize
|
||||
public dataTypeLabel = "Type"; // localize
|
||||
public attributeValueLabel = "Value"; // localize
|
||||
|
||||
/* Controls */
|
||||
public removeButtonLabel = "Remove"; // localize
|
||||
public editButtonLabel = "Edit"; // localize
|
||||
public addButtonLabel = "Add Property"; // localize
|
||||
|
||||
public edmTypes: ko.ObservableArray<string> = ko.observableArray([
|
||||
TableConstants.TableType.String,
|
||||
TableConstants.TableType.Boolean,
|
||||
TableConstants.TableType.Binary,
|
||||
TableConstants.TableType.DateTime,
|
||||
TableConstants.TableType.Double,
|
||||
TableConstants.TableType.Guid,
|
||||
TableConstants.TableType.Int32,
|
||||
TableConstants.TableType.Int64
|
||||
]);
|
||||
|
||||
public canAdd: ko.Computed<boolean>;
|
||||
public canApply: ko.Observable<boolean>;
|
||||
public displayedAttributes = ko.observableArray<EntityPropertyViewModel>();
|
||||
public editingProperty = ko.observable<EntityPropertyViewModel>();
|
||||
public isEditing = ko.observable<boolean>(false);
|
||||
public submitButtonText = ko.observable<string>();
|
||||
|
||||
public tableViewModel: TableEntityListViewModel;
|
||||
|
||||
protected scrollId: ko.Observable<string>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.container.isPreferredApiCassandra.subscribe(isCassandra => {
|
||||
if (isCassandra) {
|
||||
this.edmTypes([
|
||||
TableConstants.CassandraType.Text,
|
||||
TableConstants.CassandraType.Ascii,
|
||||
TableConstants.CassandraType.Bigint,
|
||||
TableConstants.CassandraType.Blob,
|
||||
TableConstants.CassandraType.Boolean,
|
||||
TableConstants.CassandraType.Decimal,
|
||||
TableConstants.CassandraType.Double,
|
||||
TableConstants.CassandraType.Float,
|
||||
TableConstants.CassandraType.Int,
|
||||
TableConstants.CassandraType.Uuid,
|
||||
TableConstants.CassandraType.Varchar,
|
||||
TableConstants.CassandraType.Varint,
|
||||
TableConstants.CassandraType.Inet,
|
||||
TableConstants.CassandraType.Smallint,
|
||||
TableConstants.CassandraType.Tinyint
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
this.canAdd = ko.computed<boolean>(() => {
|
||||
// Cassandra can't add since the schema can't be changed once created
|
||||
if (this.container.isPreferredApiCassandra()) {
|
||||
return false;
|
||||
}
|
||||
// Adding '2' to the maximum to take into account PartitionKey and RowKey
|
||||
return this.displayedAttributes().length < EntityPropertyViewModel.maximumNumberOfProperties + 2;
|
||||
});
|
||||
this.canApply = ko.observable<boolean>(true);
|
||||
this.editingProperty(this.displayedAttributes()[0]);
|
||||
}
|
||||
|
||||
public removeAttribute = (index: number, data: any): void => {
|
||||
this.displayedAttributes.splice(index, 1);
|
||||
this.updateIsActionEnabled();
|
||||
document.getElementById("addProperty").focus();
|
||||
};
|
||||
|
||||
public editAttribute = (index: number, data: EntityPropertyViewModel): void => {
|
||||
this.editingProperty(data);
|
||||
this.isEditing(true);
|
||||
document.getElementById("textAreaEditProperty").focus();
|
||||
};
|
||||
|
||||
public finishEditingAttribute = (): void => {
|
||||
this.isEditing(false);
|
||||
this.editingProperty(null);
|
||||
};
|
||||
|
||||
public onKeyUp = (data: any, event: KeyboardEvent): boolean => {
|
||||
var handled: boolean = Utilities.onEsc(event, ($sourceElement: JQuery) => {
|
||||
this.finishEditingAttribute();
|
||||
});
|
||||
|
||||
return !handled;
|
||||
};
|
||||
|
||||
public onAddPropertyKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.insertAttribute();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public onEditPropertyKeyDown = (
|
||||
index: number,
|
||||
data: EntityPropertyViewModel,
|
||||
event: KeyboardEvent,
|
||||
source: any
|
||||
): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.editAttribute(index, data);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public onDeletePropertyKeyDown = (
|
||||
index: number,
|
||||
data: EntityPropertyViewModel,
|
||||
event: KeyboardEvent,
|
||||
source: any
|
||||
): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.removeAttribute(index, data);
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public onBackButtonKeyDown = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === KeyCodes.Enter || event.keyCode === KeyCodes.Space) {
|
||||
this.finishEditingAttribute();
|
||||
event.stopPropagation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
public insertAttribute = (name?: string, type?: string): void => {
|
||||
let entityProperty: EntityPropertyViewModel;
|
||||
if (!!name && !!type && this.container.isPreferredApiCassandra()) {
|
||||
// TODO figure out validation story for blob and Inet so we can allow adding/editing them
|
||||
const nonEditableType: boolean =
|
||||
type === TableConstants.CassandraType.Blob || type === TableConstants.CassandraType.Inet;
|
||||
entityProperty = new EntityPropertyViewModel(
|
||||
this,
|
||||
name,
|
||||
type,
|
||||
"", // default to empty string
|
||||
/* namePlaceholder */ undefined,
|
||||
/* valuePlaceholder */ undefined,
|
||||
/* editable */ false,
|
||||
/* default valid name */ false,
|
||||
/* default valid value */ true,
|
||||
/* isRequired */ false,
|
||||
/* removable */ false,
|
||||
/*value editable*/ !nonEditableType
|
||||
);
|
||||
} else {
|
||||
entityProperty = new EntityPropertyViewModel(
|
||||
this,
|
||||
"",
|
||||
this.edmTypes()[0], // default to the first Edm type: 'string'
|
||||
"", // default to empty string
|
||||
/* namePlaceholder */ undefined,
|
||||
/* valuePlaceholder */ undefined,
|
||||
/* editable */ true,
|
||||
/* default valid name */ false,
|
||||
/* default valid value */ true
|
||||
);
|
||||
}
|
||||
|
||||
this.displayedAttributes.push(entityProperty);
|
||||
this.updateIsActionEnabled();
|
||||
this.scrollToBottom();
|
||||
|
||||
entityProperty.hasFocus(true);
|
||||
};
|
||||
|
||||
public updateIsActionEnabled(needRequiredFields: boolean = true): void {
|
||||
var properties: EntityPropertyViewModel[] = this.displayedAttributes() || [];
|
||||
var disable: boolean = _.some(properties, (property: EntityPropertyViewModel) => {
|
||||
return property.isInvalidName() || property.isInvalidValue();
|
||||
});
|
||||
|
||||
this.canApply(!disable);
|
||||
}
|
||||
|
||||
protected entityFromAttributes(displayedAttributes: EntityPropertyViewModel[]): Entities.ITableEntity {
|
||||
var entity: any = {};
|
||||
|
||||
displayedAttributes &&
|
||||
displayedAttributes.forEach((attribute: EntityPropertyViewModel) => {
|
||||
if (attribute.name() && (attribute.value() !== "" || attribute.isRequired)) {
|
||||
var value = attribute.getPropertyValue();
|
||||
var type = attribute.type();
|
||||
if (type === TableConstants.TableType.Int64) {
|
||||
value = Utilities.padLongWithZeros(value);
|
||||
}
|
||||
entity[attribute.name()] = {
|
||||
_: value,
|
||||
$: type
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
// Removing Binary from Add Entity dialog until we have a full story for it.
|
||||
protected setOptionDisable(option: Node, value: string): void {
|
||||
ko.applyBindingsToNode(option, { disable: value === TableConstants.TableType.Binary }, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the updated entity to see if there are any new attributes that old headers don't have.
|
||||
* In this case, add these attributes names as new headers.
|
||||
* Remarks: adding new headers will automatically trigger table redraw.
|
||||
*/
|
||||
protected tryInsertNewHeaders(viewModel: TableEntityListViewModel, newEntity: Entities.ITableEntity): boolean {
|
||||
var newHeaders: string[] = [];
|
||||
const keys = Object.keys(newEntity);
|
||||
keys &&
|
||||
keys.forEach((key: string) => {
|
||||
if (
|
||||
!_.contains(viewModel.headers, key) &&
|
||||
key !== TableEntityProcessor.keyProperties.attachments &&
|
||||
key !== TableEntityProcessor.keyProperties.etag &&
|
||||
key !== TableEntityProcessor.keyProperties.resourceId &&
|
||||
key !== TableEntityProcessor.keyProperties.self &&
|
||||
(!viewModel.queryTablesTab.container.isPreferredApiCassandra() ||
|
||||
key !== TableConstants.EntityKeyNames.RowKey)
|
||||
) {
|
||||
newHeaders.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
var newHeadersInserted: boolean = false;
|
||||
if (newHeaders.length) {
|
||||
if (!DataTableUtilities.checkForDefaultHeader(viewModel.headers)) {
|
||||
newHeaders = viewModel.headers.concat(newHeaders);
|
||||
}
|
||||
viewModel.updateHeaders(newHeaders, /* notifyColumnChanges */ true, /* enablePrompt */ false);
|
||||
newHeadersInserted = true;
|
||||
}
|
||||
return newHeadersInserted;
|
||||
}
|
||||
|
||||
protected scrollToBottom(): void {
|
||||
var scrollBox = document.getElementById(this.scrollId());
|
||||
var isScrolledToBottom = scrollBox.scrollHeight - scrollBox.clientHeight <= scrollBox.scrollHeight + 1;
|
||||
if (isScrolledToBottom) {
|
||||
scrollBox.scrollTop = scrollBox.scrollHeight - scrollBox.clientHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/Explorer/Panes/Tables/TableQuerySelectPane.html
Normal file
79
src/Explorer/Panes/Tables/TableQuerySelectPane.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<div data-bind="visible: visible">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="queryselectpane">
|
||||
<!-- Query Select form - Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form
|
||||
class="paneContentContainer"
|
||||
data-bind="
|
||||
submit: submit"
|
||||
>
|
||||
<!-- Query Select header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
Select Column
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keydown: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Query Select header - End -->
|
||||
<div class="paneMainContent paneContentContainer">
|
||||
<!--<div class="row">
|
||||
<label id="instructionLabel" data-bind="text: instructionLabel"></label>
|
||||
</div>-->
|
||||
<div><span>Select the columns that you want to query.</span></div>
|
||||
<div class="column-options">
|
||||
<div class="columns-border">
|
||||
<input class="all-select-check" type="checkbox" data-bind="checked: allSelected" />
|
||||
<label
|
||||
style="font-weight:700;"
|
||||
id="availableColumnsTableQueryLabel"
|
||||
data-bind="text: availableColumnsTableQueryLabel"
|
||||
></label>
|
||||
</div>
|
||||
<div class="content">
|
||||
<section>
|
||||
<ul data-bind="foreach: columnOptions" aria-labelledby="availableColumnsTableQueryLabel" tabindex="0">
|
||||
<!-- ko template: {if: editable} -->
|
||||
<li
|
||||
class="list-item columns-border"
|
||||
data-bind="attr: { title: columnName }, click: $parent.handleClick "
|
||||
>
|
||||
<input type="checkbox" data-bind="attr: { title: columnName }, checked: selected" />
|
||||
<span data-bind="text: columnName"></span>
|
||||
</li>
|
||||
<!--/ko-->
|
||||
<!-- ko template: {ifnot: editable} -->
|
||||
<li class="list-item columns-border" data-bind="attr: { title: columnName } ">
|
||||
<input type="checkbox" disabled data-bind="checked: selected" />
|
||||
<span data-bind="text: columnName"></span>
|
||||
</li>
|
||||
<!--/ko-->
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row-label" data-bind="style: { visibility: anyColumnSelected() ? 'hidden': 'visible' }">
|
||||
<label class="warning" role="alert" aria-atomic="true" data-bind="text: noColumnSelectedWarning"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="OK" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Query Select form - End -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,306 @@
|
||||
/* Constants */
|
||||
var MaximumNameLength = 255;
|
||||
var noHelp = "";
|
||||
var detailedHelp = "Enter a name up to 255 characters in size. Most valid C# identifiers are allowed."; // localize
|
||||
|
||||
export interface IValidationResult {
|
||||
isInvalid: boolean;
|
||||
help: string;
|
||||
}
|
||||
|
||||
export function validate(name: string): IValidationResult {
|
||||
var help: string = noHelp;
|
||||
// Note: Disabling encoding check to err the side of lax validation.
|
||||
// A valid property name should also be XML-serializable.
|
||||
// Hence, only allowing names that don't require special encoding for network transmission.
|
||||
// var encoded: string = tryEncode(name);
|
||||
// var success: boolean = (name === encoded);
|
||||
var success: boolean = true;
|
||||
|
||||
if (success) {
|
||||
success = name.length <= MaximumNameLength;
|
||||
if (success) {
|
||||
success = IsValidIdentifier(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
help = detailedHelp;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
|
||||
/*
|
||||
function tryEncode(name: string): string {
|
||||
var encoded: string = null;
|
||||
|
||||
try {
|
||||
encoded = encodeURIComponent(name);
|
||||
} catch (error) {
|
||||
console.error("tryEncode", "Error encoding:", name, error);
|
||||
|
||||
encoded = null;
|
||||
}
|
||||
|
||||
return encoded;
|
||||
}
|
||||
*/
|
||||
|
||||
// Port of http://referencesource.microsoft.com/#System/compmod/microsoft/csharp/csharpcodeprovider.cs,7b5c20ff8d28dfa7
|
||||
function IsValidIdentifier(value: string): boolean {
|
||||
// identifiers must be 1 char or longer
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.length > 512) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: Disabling keyword check to err the side of lax validation.
|
||||
// identifiers cannot be a keyword, unless they are escaped with an '@'
|
||||
/*
|
||||
if (value[0] !== "@") {
|
||||
if (IsKeyword(value)) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
value = value.substring(1);
|
||||
}
|
||||
*/
|
||||
|
||||
return IsValidLanguageIndependentIdentifier(value);
|
||||
}
|
||||
|
||||
/*
|
||||
var keywords: string[][] = [
|
||||
// 2 characters
|
||||
[
|
||||
"as",
|
||||
"do",
|
||||
"if",
|
||||
"in",
|
||||
"is",
|
||||
],
|
||||
// 3 characters
|
||||
[
|
||||
"for",
|
||||
"int",
|
||||
"new",
|
||||
"out",
|
||||
"ref",
|
||||
"try",
|
||||
],
|
||||
// 4 characters
|
||||
[
|
||||
"base",
|
||||
"bool",
|
||||
"byte",
|
||||
"case",
|
||||
"char",
|
||||
"else",
|
||||
"enum",
|
||||
"goto",
|
||||
"lock",
|
||||
"long",
|
||||
"null",
|
||||
"this",
|
||||
"true",
|
||||
"uint",
|
||||
"void",
|
||||
],
|
||||
// 5 characters
|
||||
[
|
||||
"break",
|
||||
"catch",
|
||||
"class",
|
||||
"const",
|
||||
"event",
|
||||
"false",
|
||||
"fixed",
|
||||
"float",
|
||||
"sbyte",
|
||||
"short",
|
||||
"throw",
|
||||
"ulong",
|
||||
"using",
|
||||
"while",
|
||||
],
|
||||
// 6 characters
|
||||
[
|
||||
"double",
|
||||
"extern",
|
||||
"object",
|
||||
"params",
|
||||
"public",
|
||||
"return",
|
||||
"sealed",
|
||||
"sizeof",
|
||||
"static",
|
||||
"string",
|
||||
"struct",
|
||||
"switch",
|
||||
"typeof",
|
||||
"unsafe",
|
||||
"ushort",
|
||||
],
|
||||
// 7 characters
|
||||
[
|
||||
"checked",
|
||||
"decimal",
|
||||
"default",
|
||||
"finally",
|
||||
"foreach",
|
||||
"private",
|
||||
"virtual",
|
||||
],
|
||||
// 8 characters
|
||||
[
|
||||
"abstract",
|
||||
"continue",
|
||||
"delegate",
|
||||
"explicit",
|
||||
"implicit",
|
||||
"internal",
|
||||
"operator",
|
||||
"override",
|
||||
"readonly",
|
||||
"volatile",
|
||||
],
|
||||
// 9 characters
|
||||
[
|
||||
"__arglist",
|
||||
"__makeref",
|
||||
"__reftype",
|
||||
"interface",
|
||||
"namespace",
|
||||
"protected",
|
||||
"unchecked",
|
||||
],
|
||||
// 10 characters
|
||||
[
|
||||
"__refvalue",
|
||||
"stackalloc",
|
||||
]
|
||||
];
|
||||
|
||||
function IsKeyword(value: string): boolean {
|
||||
var isKeyword: boolean = false;
|
||||
var listCount: number = keywords.length;
|
||||
|
||||
for (var i = 0; ((i < listCount) && !isKeyword); ++i) {
|
||||
var list: string[] = keywords[i];
|
||||
var listKeywordCount: number = list.length;
|
||||
|
||||
for (var j = 0; ((j < listKeywordCount) && !isKeyword); ++j) {
|
||||
var keyword: string = list[j];
|
||||
|
||||
isKeyword = (value === keyword);
|
||||
}
|
||||
}
|
||||
|
||||
return isKeyword;
|
||||
}
|
||||
*/
|
||||
|
||||
function IsValidLanguageIndependentIdentifier(value: string): boolean {
|
||||
return IsValidTypeNameOrIdentifier(value, /* isTypeName */ false);
|
||||
}
|
||||
|
||||
var UnicodeCategory = {
|
||||
// Uppercase Letter
|
||||
Lu: /[A-ZÀ-ÖØ-ÞĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮİIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŸ-ŹŻŽƁ-ƂƄƆ-ƇƉ-ƋƎ-ƑƓ-ƔƖ-ƘƜ-ƝƟ-ƠƢƤƦ-ƧƩƬƮ-ƯƱ-ƳƵƷ-ƸƼDŽLJNJǍǏǑǓǕǗǙǛǞǠǢǤǦǨǪǬǮDZǴǶ-ǸǺǼǾȀȂȄȆȈȊȌȎȐȒȔȖȘȚȜȞȠȢȤȦȨȪȬȮȰȲȺ-ȻȽ-ȾɁɃ-ɆɈɊɌɎͰͲͶΆΈ-ΊΌΎ-ΏΑ-ΡΣ-ΫϏϒ-ϔϘϚϜϞϠϢϤϦϨϪϬϮϴϷϹ-ϺϽ-ЯѠѢѤѦѨѪѬѮѰѲѴѶѸѺѼѾҀҊҌҎҐҒҔҖҘҚҜҞҠҢҤҦҨҪҬҮҰҲҴҶҸҺҼҾӀ-ӁӃӅӇӉӋӍӐӒӔӖӘӚӜӞӠӢӤӦӨӪӬӮӰӲӴӶӸӺӼӾԀԂԄԆԈԊԌԎԐԒԔԖԘԚԜԞԠԢԱ-ՖႠ-ჅḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẞẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸỺỼỾἈ-ἏἘ-ἝἨ-ἯἸ-ἿὈ-ὍὙὛὝὟὨ-ὯᾸ-ΆῈ-ΉῘ-ΊῨ-ῬῸ-Ώℂℇℋ-ℍℐ-ℒℕℙ-ℝℤΩℨK-ℭℰ-ℳℾ-ℿⅅↃⰀ-ⰮⱠⱢ-ⱤⱧⱩⱫⱭ-ⱯⱲⱵⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢꙀꙂꙄꙆꙈꙊꙌꙎꙐꙒꙔꙖꙘꙚꙜꙞꙢꙤꙦꙨꙪꙬꚀꚂꚄꚆꚈꚊꚌꚎꚐꚒꚔꚖꜢꜤꜦꜨꜪꜬꜮꜲꜴꜶꜸꜺꜼꜾꝀꝂꝄꝆꝈꝊꝌꝎꝐꝒꝔꝖꝘꝚꝜꝞꝠꝢꝤꝦꝨꝪꝬꝮꝹꝻꝽ-ꝾꞀꞂꞄꞆꞋA-Z]|\ud801[\udc00-\udc27]|\ud835[\udc00-\udc19\udc34-\udc4d\udc68-\udc81\udc9c\udc9e-\udc9f\udca2\udca5-\udca6\udca9-\udcac\udcae-\udcb5\udcd0-\udce9\udd04-\udd05\udd07-\udd0a\udd0d-\udd14\udd16-\udd1c\udd38-\udd39\udd3b-\udd3e\udd40-\udd44\udd46\udd4a-\udd50\udd6c-\udd85\udda0-\uddb9\uddd4-\udded\ude08-\ude21\ude3c-\ude55\ude70-\ude89\udea8-\udec0\udee2-\udefa\udf1c-\udf34\udf56-\udf6e\udf90-\udfa8\udfca]/,
|
||||
// Lowercase Letter
|
||||
Ll: /[a-zªµºß-öø-ÿāăąćĉċčďđēĕėęěĝğġģĥħĩīĭįıijĵķ-ĸĺļľŀłńņň-ʼnŋōŏőœŕŗřśŝşšţťŧũūŭůűųŵŷźżž-ƀƃƅƈƌ-ƍƒƕƙ-ƛƞơƣƥƨƪ-ƫƭưƴƶƹ-ƺƽ-ƿdžljnjǎǐǒǔǖǘǚǜ-ǝǟǡǣǥǧǩǫǭǯ-ǰdzǵǹǻǽǿȁȃȅȇȉȋȍȏȑȓȕȗșțȝȟȡȣȥȧȩȫȭȯȱȳ-ȹȼȿ-ɀɂɇɉɋɍɏ-ʓʕ-ʯͱͳͷͻ-ͽΐά-ώϐ-ϑϕ-ϗϙϛϝϟϡϣϥϧϩϫϭϯ-ϳϵϸϻ-ϼа-џѡѣѥѧѩѫѭѯѱѳѵѷѹѻѽѿҁҋҍҏґғҕҗҙқҝҟҡңҥҧҩҫҭүұҳҵҷҹһҽҿӂӄӆӈӊӌӎ-ӏӑӓӕӗәӛӝӟӡӣӥӧөӫӭӯӱӳӵӷӹӻӽӿԁԃԅԇԉԋԍԏԑԓԕԗԙԛԝԟԡԣա-ևᴀ-ᴫᵢ-ᵷᵹ-ᶚḁḃḅḇḉḋḍḏḑḓḕḗḙḛḝḟḡḣḥḧḩḫḭḯḱḳḵḷḹḻḽḿṁṃṅṇṉṋṍṏṑṓṕṗṙṛṝṟṡṣṥṧṩṫṭṯṱṳṵṷṹṻṽṿẁẃẅẇẉẋẍẏẑẓẕ-ẝẟạảấầẩẫậắằẳẵặẹẻẽếềểễệỉịọỏốồổỗộớờởỡợụủứừửữựỳỵỷỹỻỽỿ-ἇἐ-ἕἠ-ἧἰ-ἷὀ-ὅὐ-ὗὠ-ὧὰ-ώᾀ-ᾇᾐ-ᾗᾠ-ᾧᾰ-ᾴᾶ-ᾷιῂ-ῄῆ-ῇῐ-ΐῖ-ῗῠ-ῧῲ-ῴῶ-ῷⁱⁿℊℎ-ℏℓℯℴℹℼ-ℽⅆ-ⅉⅎↄⰰ-ⱞⱡⱥ-ⱦⱨⱪⱬⱱⱳ-ⱴⱶ-ⱼⲁⲃⲅⲇⲉⲋⲍⲏⲑⲓⲕⲗⲙⲛⲝⲟⲡⲣⲥⲧⲩⲫⲭⲯⲱⲳⲵⲷⲹⲻⲽⲿⳁⳃⳅⳇⳉⳋⳍⳏⳑⳓⳕⳗⳙⳛⳝⳟⳡⳣ-ⳤⴀ-ⴥꙁꙃꙅꙇꙉꙋꙍꙏꙑꙓꙕꙗꙙꙛꙝꙟꙣꙥꙧꙩꙫꙭꚁꚃꚅꚇꚉꚋꚍꚏꚑꚓꚕꚗꜣꜥꜧꜩꜫꜭꜯ-ꜱꜳꜵꜷꜹꜻꜽꜿꝁꝃꝅꝇꝉꝋꝍꝏꝑꝓꝕꝗꝙꝛꝝꝟꝡꝣꝥꝧꝩꝫꝭꝯꝱ-ꝸꝺꝼꝿꞁꞃꞅꞇꞌff-stﬓ-ﬗa-z]|\ud801[\udc28-\udc4f]|\ud835[\udc1a-\udc33\udc4e-\udc54\udc56-\udc67\udc82-\udc9b\udcb6-\udcb9\udcbb\udcbd-\udcc3\udcc5-\udccf\udcea-\udd03\udd1e-\udd37\udd52-\udd6b\udd86-\udd9f\uddba-\uddd3\uddee-\ude07\ude22-\ude3b\ude56-\ude6f\ude8a-\udea5\udec2-\udeda\udedc-\udee1\udefc-\udf14\udf16-\udf1b\udf36-\udf4e\udf50-\udf55\udf70-\udf88\udf8a-\udf8f\udfaa-\udfc2\udfc4-\udfc9\udfcb]/,
|
||||
// Titlecase Letter
|
||||
Lt: /[DžLjNjDzᾈ-ᾏᾘ-ᾟᾨ-ᾯᾼῌῼ]/,
|
||||
// Modifier/Number Letter
|
||||
Lm: /[ʰ-ˁˆ-ˑˠ-ˤˬˮʹͺՙـۥ-ۦߴ-ߵߺॱๆໆჼៗᡃᱸ-ᱽᴬ-ᵡᵸᶛ-ᶿₐ-ₔⱽⵯⸯ々〱-〵〻ゝ-ゞー-ヾꀕꘌꙿꜗ-ꜟꝰꞈー゙-゚]/,
|
||||
// Other Letter
|
||||
Lo: /[ƻǀ-ǃʔא-תװ-ײء-ؿف-يٮ-ٯٱ-ۓەۮ-ۯۺ-ۼۿܐܒ-ܯݍ-ޥޱߊ-ߪऄ-हऽॐक़-ॡॲॻ-ॿঅ-ঌএ-ঐও-নপ-রলশ-হঽৎড়-ঢ়য়-ৡৰ-ৱਅ-ਊਏ-ਐਓ-ਨਪ-ਰਲ-ਲ਼ਵ-ਸ਼ਸ-ਹਖ਼-ੜਫ਼ੲ-ੴઅ-ઍએ-ઑઓ-નપ-રલ-ળવ-હઽૐૠ-ૡଅ-ଌଏ-ଐଓ-ନପ-ରଲ-ଳଵ-ହଽଡ଼-ଢ଼ୟ-ୡୱஃஅ-ஊஎ-ஐஒ-கங-சஜஞ-டண-தந-பம-ஹௐఅ-ఌఎ-ఐఒ-నప-ళవ-హఽౘ-ౙౠ-ౡಅ-ಌಎ-ಐಒ-ನಪ-ಳವ-ಹಽೞೠ-ೡഅ-ഌഎ-ഐഒ-നപ-ഹഽൠ-ൡൺ-ൿඅ-ඖක-නඳ-රලව-ෆก-ะา-ำเ-ๅກ-ຂຄງ-ຈຊຍດ-ທນ-ຟມ-ຣລວສ-ຫອ-ະາ-ຳຽເ-ໄໜ-ໝༀཀ-ཇཉ-ཬྈ-ྋက-ဪဿၐ-ၕၚ-ၝၡၥ-ၦၮ-ၰၵ-ႁႎა-ჺᄀ-ᅙᅟ-ᆢᆨ-ᇹሀ-ቈቊ-ቍቐ-ቖቘቚ-ቝበ-ኈኊ-ኍነ-ኰኲ-ኵኸ-ኾዀዂ-ዅወ-ዖዘ-ጐጒ-ጕጘ-ፚᎀ-ᎏᎠ-Ᏼᐁ-ᙬᙯ-ᙶᚁ-ᚚᚠ-ᛪᜀ-ᜌᜎ-ᜑᜠ-ᜱᝀ-ᝑᝠ-ᝬᝮ-ᝰក-ឳៜᠠ-ᡂᡄ-ᡷᢀ-ᢨᢪᤀ-ᤜᥐ-ᥭᥰ-ᥴᦀ-ᦩᧁ-ᧇᨀ-ᨖᬅ-ᬳᭅ-ᭋᮃ-ᮠᮮ-ᮯᰀ-ᰣᱍ-ᱏᱚ-ᱷℵ-ℸⴰ-ⵥⶀ-ⶖⶠ-ⶦⶨ-ⶮⶰ-ⶶⶸ-ⶾⷀ-ⷆⷈ-ⷎⷐ-ⷖⷘ-ⷞ〆〼ぁ-ゖゟァ-ヺヿㄅ-ㄭㄱ-ㆎㆠ-ㆷㇰ-ㇿ㐀-䶵一-鿃ꀀ-ꀔꀖ-ꒌꔀ-ꘋꘐ-ꘟꘪ-ꘫꙮꟻ-ꠁꠃ-ꠅꠇ-ꠊꠌ-ꠢꡀ-ꡳꢂ-ꢳꤊ-ꤥꤰ-ꥆꨀ-ꨨꩀ-ꩂꩄ-ꩋ가-힣豈-鶴侮-頻並-龎יִײַ-ﬨשׁ-זּטּ-לּמּנּ-סּףּ-פּצּ-ﮱﯓ-ﴽﵐ-ﶏﶒ-ﷇﷰ-ﷻﹰ-ﹴﹶ-ﻼヲ-ッア-ンᅠ-하-ᅦᅧ-ᅬᅭ-ᅲᅳ-ᅵ]|[\ud840-\ud868][\udc00-\udfff]|\ud800[\udc00-\udc0b\udc0d-\udc26\udc28-\udc3a\udc3c-\udc3d\udc3f-\udc4d\udc50-\udc5d\udc80-\udcfa\ude80-\ude9c\udea0-\uded0\udf00-\udf1e\udf30-\udf40\udf42-\udf49\udf80-\udf9d\udfa0-\udfc3\udfc8-\udfcf]|\ud801[\udc50-\udc9d]|\ud802[\udc00-\udc05\udc08\udc0a-\udc35\udc37-\udc38\udc3c\udc3f\udd00-\udd15\udd20-\udd39\ude00\ude10-\ude13\ude15-\ude17\ude19-\ude33]|\ud808[\udc00-\udf6e]|\ud869[\udc00-\uded6]|\ud87e[\udc00-\ude1d]/,
|
||||
// Non Spacing Mark
|
||||
Mn: /[\u0300-\u036f\u0483-\u0487\u0591-\u05bd\u05bf\u05c1-\u05c2\u05c4-\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7-\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0901-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0954\u0962-\u0963\u0981\u09bc\u09c1-\u09c4\u09cd\u09e2-\u09e3\u0a01-\u0a02\u0a3c\u0a41-\u0a42\u0a47-\u0a48\u0a4b-\u0a4d\u0a51\u0a70-\u0a71\u0a75\u0a81-\u0a82\u0abc\u0ac1-\u0ac5\u0ac7-\u0ac8\u0acd\u0ae2-\u0ae3\u0b01\u0b3c\u0b3f\u0b41-\u0b44\u0b4d\u0b56\u0b62-\u0b63\u0b82\u0bc0\u0bcd\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55-\u0c56\u0c62-\u0c63\u0cbc\u0cbf\u0cc6\u0ccc-\u0ccd\u0ce2-\u0ce3\u0d41-\u0d44\u0d4d\u0d62-\u0d63\u0dca\u0dd2-\u0dd4\u0dd6\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1\u0eb4-\u0eb9\u0ebb-\u0ebc\u0ec8-\u0ecd\u0f18-\u0f19\u0f35\u0f37\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86-\u0f87\u0f90-\u0f97\u0f99-\u0fbc\u0fc6\u102d-\u1030\u1032-\u1037\u1039-\u103a\u103d-\u103e\u1058-\u1059\u105e-\u1060\u1071-\u1074\u1082\u1085-\u1086\u108d\u135f\u1712-\u1714\u1732-\u1734\u1752-\u1753\u1772-\u1773\u17b7-\u17bd\u17c6\u17c9-\u17d3\u17dd\u180b-\u180d\u18a9\u1920-\u1922\u1927-\u1928\u1932\u1939-\u193b\u1a17-\u1a18\u1b00-\u1b03\u1b34\u1b36-\u1b3a\u1b3c\u1b42\u1b6b-\u1b73\u1b80-\u1b81\u1ba2-\u1ba5\u1ba8-\u1ba9\u1c2c-\u1c33\u1c36-\u1c37\u1dc0-\u1de6\u1dfe-\u1dff\u20d0-\u20dc\u20e1\u20e5-\u20f0\u2de0-\u2dff\u302a-\u302f\u3099-\u309a\ua66f\ua67c-\ua67d\ua802\ua806\ua80b\ua825-\ua826\ua8c4\ua926-\ua92d\ua947-\ua951\uaa29-\uaa2e\uaa31-\uaa32\uaa35-\uaa36\uaa43\uaa4c\ufb1e\ufe00-\ufe0f\ufe20-\ufe26]|\ud800\uddfd|\ud802[\ude01-\ude03\ude05-\ude06\ude0c-\ude0f\ude38-\ude3a\ude3f]|\ud834[\udd67-\udd69\udd7b-\udd82\udd85-\udd8b\uddaa-\uddad\ude42-\ude44]|\udb40[\udd00-\uddef]/,
|
||||
// Spacing Combining Mark
|
||||
Mc: /[\u0903\u093e-\u0940\u0949-\u094c\u0982-\u0983\u09be-\u09c0\u09c7-\u09c8\u09cb-\u09cc\u09d7\u0a03\u0a3e-\u0a40\u0a83\u0abe-\u0ac0\u0ac9\u0acb-\u0acc\u0b02-\u0b03\u0b3e\u0b40\u0b47-\u0b48\u0b4b-\u0b4c\u0b57\u0bbe-\u0bbf\u0bc1-\u0bc2\u0bc6-\u0bc8\u0bca-\u0bcc\u0bd7\u0c01-\u0c03\u0c41-\u0c44\u0c82-\u0c83\u0cbe\u0cc0-\u0cc4\u0cc7-\u0cc8\u0cca-\u0ccb\u0cd5-\u0cd6\u0d02-\u0d03\u0d3e-\u0d40\u0d46-\u0d48\u0d4a-\u0d4c\u0d57\u0d82-\u0d83\u0dcf-\u0dd1\u0dd8-\u0ddf\u0df2-\u0df3\u0f3e-\u0f3f\u0f7f\u102b-\u102c\u1031\u1038\u103b-\u103c\u1056-\u1057\u1062-\u1064\u1067-\u106d\u1083-\u1084\u1087-\u108c\u108f\u17b6\u17be-\u17c5\u17c7-\u17c8\u1923-\u1926\u1929-\u192b\u1930-\u1931\u1933-\u1938\u19b0-\u19c0\u19c8-\u19c9\u1a19-\u1a1b\u1b04\u1b35\u1b3b\u1b3d-\u1b41\u1b43-\u1b44\u1b82\u1ba1\u1ba6-\u1ba7\u1baa\u1c24-\u1c2b\u1c34-\u1c35\ua823-\ua824\ua827\ua880-\ua881\ua8b4-\ua8c3\ua952-\ua953\uaa2f-\uaa30\uaa33-\uaa34\uaa4d]|\ud834[\udd65-\udd66\udd6d-\udd72]/,
|
||||
// Connector Punctuation
|
||||
Pc: /[_‿-⁀⁔︳-︴﹍-﹏_]/,
|
||||
// Decimal Digit Number
|
||||
Nd: /[0-9٠-٩۰-۹߀-߉०-९০-৯੦-੯૦-૯୦-୯௦-௯౦-౯೦-೯൦-൯๐-๙໐-໙༠-༩၀-၉႐-႙០-៩᠐-᠙᥆-᥏᧐-᧙᭐-᭙᮰-᮹᱀-᱉᱐-᱙꘠-꘩꣐-꣙꤀-꤉꩐-꩙0-9]|\ud801[\udca0-\udca9]|\ud835[\udfce-\udfff]/
|
||||
};
|
||||
|
||||
function IsValidTypeNameOrIdentifier(value: string, isTypeName: boolean): boolean {
|
||||
var nextMustBeStartChar: boolean = true;
|
||||
|
||||
if (!value.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// each char must be Lu, Ll, Lt, Lm, Lo, Nd, Mn, Mc, Pc
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
var ch: string = value[i];
|
||||
|
||||
if (
|
||||
UnicodeCategory.Lu.test(ch) ||
|
||||
UnicodeCategory.Ll.test(ch) ||
|
||||
UnicodeCategory.Lt.test(ch) ||
|
||||
UnicodeCategory.Lm.test(ch) ||
|
||||
UnicodeCategory.Lo.test(ch)
|
||||
) {
|
||||
nextMustBeStartChar = false;
|
||||
} else if (
|
||||
UnicodeCategory.Mn.test(ch) ||
|
||||
UnicodeCategory.Mc.test(ch) ||
|
||||
UnicodeCategory.Pc.test(ch) ||
|
||||
UnicodeCategory.Nd.test(ch)
|
||||
) {
|
||||
// Underscore is a valid starting character, even though it is a ConnectorPunctuation.
|
||||
if (nextMustBeStartChar && ch !== "_") {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextMustBeStartChar = false;
|
||||
} else {
|
||||
// We only check the special Type chars for type names.
|
||||
if (!isTypeName) {
|
||||
return false;
|
||||
} else {
|
||||
var ref: { nextMustBeStartChar: boolean } = { nextMustBeStartChar: nextMustBeStartChar };
|
||||
var isSpecialTypeChar = IsSpecialTypeChar(ch, ref);
|
||||
|
||||
nextMustBeStartChar = ref.nextMustBeStartChar;
|
||||
|
||||
if (!isSpecialTypeChar) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// This can be a special character like a separator that shows up in a type name
|
||||
// This is an odd set of characters. Some come from characters that are allowed by C++, like < and >.
|
||||
// Others are characters that are specified in the type and assembly name grammer.
|
||||
function IsSpecialTypeChar(ch: string, ref: { nextMustBeStartChar: boolean }): boolean {
|
||||
switch (ch) {
|
||||
case ":":
|
||||
case ".":
|
||||
case "$":
|
||||
case "+":
|
||||
case "<":
|
||||
case ">":
|
||||
case "-":
|
||||
case "[":
|
||||
case "]":
|
||||
case ",":
|
||||
case "&":
|
||||
case "*":
|
||||
ref.nextMustBeStartChar = true;
|
||||
return true;
|
||||
case "`":
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export var Int32 = {
|
||||
Min: -2147483648,
|
||||
Max: 2147483647
|
||||
};
|
||||
|
||||
export var Int64 = {
|
||||
Min: -9223372036854775808,
|
||||
Max: 9223372036854775807
|
||||
};
|
||||
|
||||
var yearMonthDay = "\\d{4}[- ][01]\\d[- ][0-3]\\d";
|
||||
var timeOfDay = "T[0-2]\\d:[0-5]\\d(:[0-5]\\d(\\.\\d+)?)?";
|
||||
var timeZone = "Z|[+-][0-2]\\d:[0-5]\\d";
|
||||
|
||||
export var ValidationRegExp = {
|
||||
Guid: /^[{(]?[0-9A-F]{8}[-]?([0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$/i,
|
||||
Float: /^[+-]?\d+(\.\d+)?(e[+-]?\d+)?$/i,
|
||||
// OData seems to require an "L" suffix for Int64 values, yet Azure Storage errors out with it. See http://www.odata.org/documentation/odata-version-2-0/overview/
|
||||
Integer: /^[+-]?\d+$/i, // Used for both Int32 and Int64 values
|
||||
Boolean: /^"?(true|false)"?$/i,
|
||||
DateTime: new RegExp(`^${yearMonthDay}${timeOfDay}${timeZone}$`),
|
||||
PrimaryKey: /^[^/\\#?\u0000-\u001F\u007F-\u009F]*$/
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
import * as Utilities from "../../../Tables/Utilities";
|
||||
import * as StorageExplorerConstants from "../../../Tables/Constants";
|
||||
import * as EntityPropertyValidationCommon from "./EntityPropertyValidationCommon";
|
||||
|
||||
interface IValidationResult {
|
||||
isInvalid: boolean;
|
||||
help: string;
|
||||
}
|
||||
|
||||
interface IValueValidator {
|
||||
validate: (value: string) => IValidationResult;
|
||||
parseValue: (value: string) => any;
|
||||
}
|
||||
|
||||
/* Constants */
|
||||
var noHelp: string = "";
|
||||
var MaximumStringLength = 64 * 1024; // 64 KB
|
||||
var MaximumRequiredStringLength = 1 * 1024; // 1 KB
|
||||
|
||||
class ValueValidator implements IValueValidator {
|
||||
public validate(value: string): IValidationResult {
|
||||
// throw new Errors.NotImplementedFunctionError("ValueValidator.validate");
|
||||
return null;
|
||||
}
|
||||
|
||||
public parseValue(value: string): any {
|
||||
return value; // default pass-thru implementation
|
||||
}
|
||||
}
|
||||
|
||||
class KeyValidator implements ValueValidator {
|
||||
private static detailedHelp = "Enter a string ('/', '\\', '#', '?' and control characters not allowed)."; // Localize
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
if (
|
||||
value == null ||
|
||||
value.trim().length === 0 ||
|
||||
EntityPropertyValidationCommon.ValidationRegExp.PrimaryKey.test(value)
|
||||
) {
|
||||
return { isInvalid: false, help: noHelp };
|
||||
} else {
|
||||
return { isInvalid: true, help: KeyValidator.detailedHelp };
|
||||
}
|
||||
}
|
||||
|
||||
public parseValue(value: string): string {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class BooleanValueValidator extends ValueValidator {
|
||||
private detailedHelp = "Enter true or false."; // localize
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
var success: boolean = false;
|
||||
var help: string = noHelp;
|
||||
|
||||
if (value) {
|
||||
success = EntityPropertyValidationCommon.ValidationRegExp.Boolean.test(value);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
help = this.detailedHelp;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
|
||||
public parseValue(value: string): boolean {
|
||||
// OData seems to require lowercase boolean values, see http://www.odata.org/documentation/odata-version-2-0/overview/
|
||||
return value.toString().toLowerCase() === "true";
|
||||
}
|
||||
}
|
||||
|
||||
class DateTimeValueValidator extends ValueValidator {
|
||||
private detailedHelp = "Enter a date and time."; // localize
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
var success: boolean = false;
|
||||
var help: string = noHelp;
|
||||
|
||||
if (value) {
|
||||
// Try to parse the value to see if it is a valid date string
|
||||
var parsed: number = Date.parse(value);
|
||||
|
||||
success = !isNaN(parsed);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
help = this.detailedHelp;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
|
||||
public parseValue(value: string): Date {
|
||||
var millisecondTime = Date.parse(value);
|
||||
var parsed: Date = new Date(millisecondTime);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
class DoubleValueValidator extends ValueValidator {
|
||||
private detailedHelp = "Enter a 64-bit floating point value."; // localize
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
var success: boolean = false;
|
||||
var help: string = noHelp;
|
||||
|
||||
if (value) {
|
||||
success = EntityPropertyValidationCommon.ValidationRegExp.Float.test(value);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
help = this.detailedHelp;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
|
||||
public parseValue(value: string): number {
|
||||
return parseFloat(value);
|
||||
}
|
||||
}
|
||||
|
||||
class GuidValueValidator extends ValueValidator {
|
||||
private detailedHelp = "Enter a 16-byte (128-bit) GUID value."; // localize
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
var success: boolean = false;
|
||||
var help: string = noHelp;
|
||||
|
||||
if (value) {
|
||||
success = EntityPropertyValidationCommon.ValidationRegExp.Guid.test(value);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
help = this.detailedHelp;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
}
|
||||
|
||||
class IntegerValueValidator extends ValueValidator {
|
||||
private detailedInt32Help = "Enter a signed 32-bit integer."; // localize
|
||||
private detailedInt64Help = "Enter a signed 64-bit integer, in the range (-2^53 - 1, 2^53 - 1)."; // localize
|
||||
|
||||
private isInt64: boolean;
|
||||
|
||||
constructor(isInt64: boolean = true) {
|
||||
super();
|
||||
|
||||
this.isInt64 = isInt64;
|
||||
}
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
var success: boolean = false;
|
||||
var help: string = noHelp;
|
||||
|
||||
if (value) {
|
||||
success = EntityPropertyValidationCommon.ValidationRegExp.Integer.test(value) && Utilities.isSafeInteger(value);
|
||||
if (success) {
|
||||
var intValue = parseInt(value, 10);
|
||||
|
||||
success = !isNaN(intValue);
|
||||
if (success && !this.isInt64) {
|
||||
success =
|
||||
EntityPropertyValidationCommon.Int32.Min <= intValue &&
|
||||
intValue <= EntityPropertyValidationCommon.Int32.Max;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
help = this.isInt64 ? this.detailedInt64Help : this.detailedInt32Help;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
|
||||
public parseValue(value: string): number {
|
||||
return parseInt(value, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow all values for string type, unless the property is required, in which case an empty string is invalid.
|
||||
class StringValidator extends ValueValidator {
|
||||
private detailedHelp = "Enter a value up to 64 KB in size."; // localize
|
||||
private isRequiredHelp = "Enter a value up to 1 KB in size."; // localize
|
||||
private emptyStringHelp = "Empty string."; // localize
|
||||
private isRequired: boolean;
|
||||
|
||||
constructor(isRequired: boolean) {
|
||||
super();
|
||||
|
||||
this.isRequired = isRequired;
|
||||
}
|
||||
|
||||
public validate(value: string): IValidationResult {
|
||||
var help: string = this.isRequired ? this.isRequiredHelp : this.detailedHelp;
|
||||
if (value === null) {
|
||||
return { isInvalid: false, help: help };
|
||||
}
|
||||
// Ensure we validate the string projection of value.
|
||||
value = String(value);
|
||||
|
||||
var success = true;
|
||||
|
||||
if (success) {
|
||||
success = value.length <= (this.isRequired ? MaximumRequiredStringLength : MaximumStringLength);
|
||||
}
|
||||
|
||||
if (success && this.isRequired) {
|
||||
help = value ? noHelp : this.emptyStringHelp;
|
||||
}
|
||||
|
||||
return { isInvalid: !success, help: help };
|
||||
}
|
||||
|
||||
public parseValue(value: string): string {
|
||||
return String(value); // Ensure value is converted to string.
|
||||
}
|
||||
}
|
||||
|
||||
class NotSupportedValidator extends ValueValidator {
|
||||
private type: string;
|
||||
|
||||
constructor(type: string) {
|
||||
super();
|
||||
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public validate(ignoredValue: string): IValidationResult {
|
||||
//throw new Errors.NotSupportedError(this.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
public parseValue(ignoredValue: string): any {
|
||||
//throw new Errors.NotSupportedError(this.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
private getMessage(): string {
|
||||
return "Properties of type " + this.type + " are not supported.";
|
||||
}
|
||||
}
|
||||
|
||||
class PropertyValidatorFactory {
|
||||
public getValidator(type: string, isRequired: boolean) {
|
||||
var validator: IValueValidator = null;
|
||||
|
||||
// TODO classify rest of Cassandra types/create validators for them
|
||||
switch (type) {
|
||||
case StorageExplorerConstants.TableType.Boolean:
|
||||
case StorageExplorerConstants.CassandraType.Boolean:
|
||||
validator = new BooleanValueValidator();
|
||||
break;
|
||||
case StorageExplorerConstants.TableType.DateTime:
|
||||
validator = new DateTimeValueValidator();
|
||||
break;
|
||||
case StorageExplorerConstants.TableType.Double:
|
||||
case StorageExplorerConstants.CassandraType.Decimal:
|
||||
case StorageExplorerConstants.CassandraType.Double:
|
||||
case StorageExplorerConstants.CassandraType.Float:
|
||||
validator = new DoubleValueValidator();
|
||||
break;
|
||||
case StorageExplorerConstants.TableType.Guid:
|
||||
case StorageExplorerConstants.CassandraType.Uuid:
|
||||
validator = new GuidValueValidator();
|
||||
break;
|
||||
case StorageExplorerConstants.TableType.Int32:
|
||||
case StorageExplorerConstants.CassandraType.Int:
|
||||
// TODO create separate validators for smallint and tinyint
|
||||
case StorageExplorerConstants.CassandraType.Smallint:
|
||||
case StorageExplorerConstants.CassandraType.Tinyint:
|
||||
validator = new IntegerValueValidator(/* isInt64 */ false);
|
||||
break;
|
||||
case StorageExplorerConstants.TableType.Int64:
|
||||
case StorageExplorerConstants.CassandraType.Bigint:
|
||||
case StorageExplorerConstants.CassandraType.Varint:
|
||||
validator = new IntegerValueValidator(/* isInt64 */ true);
|
||||
break;
|
||||
case StorageExplorerConstants.TableType.String:
|
||||
case StorageExplorerConstants.CassandraType.Text:
|
||||
case StorageExplorerConstants.CassandraType.Ascii:
|
||||
case StorageExplorerConstants.CassandraType.Varchar:
|
||||
validator = new StringValidator(isRequired);
|
||||
break;
|
||||
case "Key":
|
||||
validator = new KeyValidator();
|
||||
break;
|
||||
default:
|
||||
validator = new NotSupportedValidator(type);
|
||||
break;
|
||||
}
|
||||
|
||||
return validator;
|
||||
}
|
||||
}
|
||||
|
||||
interface ITypeValidatorMap {
|
||||
[type: string]: IValueValidator;
|
||||
}
|
||||
|
||||
export default class EntityPropertyValueValidator {
|
||||
private validators: ITypeValidatorMap;
|
||||
private validatorFactory: PropertyValidatorFactory;
|
||||
private isRequired: boolean;
|
||||
|
||||
constructor(isRequired: boolean) {
|
||||
this.validators = {};
|
||||
this.validatorFactory = new PropertyValidatorFactory();
|
||||
this.isRequired = isRequired;
|
||||
}
|
||||
|
||||
public validate(value: string, type: string): IValidationResult {
|
||||
var validator: IValueValidator = this.getValidator(type);
|
||||
|
||||
return validator ? validator.validate(value) : null; // Should not happen.
|
||||
}
|
||||
|
||||
public parseValue(value: string, type: string): any {
|
||||
var validator: IValueValidator = this.getValidator(type);
|
||||
|
||||
return validator ? validator.parseValue(value) : null; // Should not happen.
|
||||
}
|
||||
|
||||
private getValidator(type: string): IValueValidator {
|
||||
var validator: IValueValidator = this.validators[type];
|
||||
|
||||
if (!validator) {
|
||||
validator = this.validatorFactory.getValidator(type, this.isRequired);
|
||||
this.validators[type] = validator;
|
||||
}
|
||||
|
||||
return validator;
|
||||
}
|
||||
}
|
||||
72
src/Explorer/Panes/UploadFilePane.html
Normal file
72
src/Explorer/Panes/UploadFilePane.html
Normal file
@@ -0,0 +1,72 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div class="contextual-pane-out" data-bind="click: cancel, clickBubble: false"></div>
|
||||
<div class="contextual-pane" id="uploadFilePane">
|
||||
<!-- Upload File form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Upload File header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div class="closeImg" role="button" aria-label="Close pane" tabindex="0" data-bind="click: cancel">
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload File header - End -->
|
||||
|
||||
<!-- Upload File errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="visible: formErrorsDetails() && formErrorsDetails() !== '', click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload File errors - End -->
|
||||
|
||||
<!-- Upload File inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<div class="renewUploadItemsHeader" data-bind="text: selectFileInputLabel"></div>
|
||||
<input class="importFilesTitle" type="text" disabled data-bind="value: selectedFilesTitle" />
|
||||
<input
|
||||
type="file"
|
||||
id="importFileInput"
|
||||
style="display: none"
|
||||
data-bind="event: { change: updateSelectedFiles }, attr: { accept: extensions }"
|
||||
/>
|
||||
<a
|
||||
href="#"
|
||||
id="fileImportLinkNotebook"
|
||||
data-bind="event: { click: onImportLinkClick, keypress: onImportLinkKeyPress }"
|
||||
>
|
||||
<img class="fileImportImg" src="/folder_16x16.svg" alt="upload files" title="Upload files" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut">
|
||||
<input type="submit" data-bind="attr: { value: submitButtonLabel }" class="btncreatecoll1" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload File inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Upload File form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
130
src/Explorer/Panes/UploadFilePane.ts
Normal file
130
src/Explorer/Panes/UploadFilePane.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
|
||||
export class UploadFilePane extends ContextualPaneBase implements ViewModels.UploadFilePane {
|
||||
public selectedFilesTitle: ko.Observable<string>;
|
||||
public files: ko.Observable<FileList>;
|
||||
private openOptions: ViewModels.UploadFilePaneOpenOptions;
|
||||
private submitButtonLabel: ko.Observable<string>;
|
||||
private selectFileInputLabel: ko.Observable<string>;
|
||||
private extensions: ko.Observable<string>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this.resetData();
|
||||
this.selectFileInputLabel = ko.observable("");
|
||||
this.selectedFilesTitle = ko.observable<string>("");
|
||||
this.extensions = ko.observable(null);
|
||||
this.submitButtonLabel = ko.observable("Load");
|
||||
this.files = ko.observable<FileList>();
|
||||
this.files.subscribe((newFiles: FileList) => this.updateSelectedFilesTitle(newFiles));
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.formErrors("");
|
||||
this.formErrorsDetails("");
|
||||
if (!this.files() || this.files().length === 0) {
|
||||
this.formErrors("No file specified");
|
||||
this.formErrorsDetails("No file specified. Please input a file.");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`${this.openOptions.errorMessage} -- No file specified. Please input a file.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const file: File = this.files().item(0);
|
||||
const id: string = NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.InProgress,
|
||||
`${this.openOptions.inProgressMessage}: ${file.name}`
|
||||
);
|
||||
this.isExecuting(true);
|
||||
this.openOptions
|
||||
.onSubmit(this.files().item(0))
|
||||
.then(
|
||||
() => {
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Info,
|
||||
`${this.openOptions.successMessage} ${file.name}`
|
||||
);
|
||||
this.close();
|
||||
},
|
||||
(error: any) => {
|
||||
this.formErrors(this.openOptions.errorMessage);
|
||||
this.formErrorsDetails(`${this.openOptions.errorMessage}: ${error}`);
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
`${this.openOptions.errorMessage} ${file.name}: ${error}`
|
||||
);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
this.isExecuting(false);
|
||||
NotificationConsoleUtils.clearInProgressMessageWithId(id);
|
||||
});
|
||||
}
|
||||
|
||||
public updateSelectedFiles(element: any, event: any): void {
|
||||
this.files(event.target.files);
|
||||
}
|
||||
|
||||
public close() {
|
||||
super.close();
|
||||
this.resetData();
|
||||
this.files(undefined);
|
||||
this.resetFileInput();
|
||||
}
|
||||
|
||||
public openWithOptions(options: ViewModels.UploadFilePaneOpenOptions): void {
|
||||
this.openOptions = options;
|
||||
this.title(this.openOptions.paneTitle);
|
||||
if (this.openOptions.submitButtonLabel) {
|
||||
this.submitButtonLabel(this.openOptions.submitButtonLabel);
|
||||
}
|
||||
this.selectFileInputLabel(this.openOptions.selectFileInputLabel);
|
||||
if (this.openOptions.extensions) {
|
||||
this.extensions(this.openOptions.extensions);
|
||||
}
|
||||
super.open();
|
||||
}
|
||||
|
||||
public onImportLinkClick(source: any, event: MouseEvent): boolean {
|
||||
document.getElementById("importFileInput").click();
|
||||
return false;
|
||||
}
|
||||
|
||||
public onImportLinkKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.onImportLinkClick(source, null);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
private updateSelectedFilesTitle(fileList: FileList) {
|
||||
this.selectedFilesTitle("");
|
||||
|
||||
if (!fileList || fileList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const originalTitle = this.selectedFilesTitle();
|
||||
this.selectedFilesTitle(originalTitle + `"${fileList.item(i).name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private resetFileInput(): void {
|
||||
const inputElement = $("#importFileInput");
|
||||
inputElement
|
||||
.wrap("<form>")
|
||||
.closest("form")
|
||||
.get(0)
|
||||
.reset();
|
||||
inputElement.unwrap();
|
||||
}
|
||||
}
|
||||
130
src/Explorer/Panes/UploadItemsPane.html
Normal file
130
src/Explorer/Panes/UploadItemsPane.html
Normal file
@@ -0,0 +1,130 @@
|
||||
<div data-bind="visible: visible, event: { keydown: onPaneKeyDown }">
|
||||
<div
|
||||
class="contextual-pane-out"
|
||||
data-bind="
|
||||
click: cancel,
|
||||
clickBubble: false"
|
||||
></div>
|
||||
<div class="contextual-pane" id="uploaditemspane">
|
||||
<!-- Upload items form -- Start -->
|
||||
<div class="contextual-pane-in">
|
||||
<form class="paneContentContainer" data-bind="submit: submit">
|
||||
<!-- Upload items header - Start -->
|
||||
<div class="firstdivbg headerline">
|
||||
<span data-bind="text: title"></span>
|
||||
<div
|
||||
class="closeImg"
|
||||
role="button"
|
||||
aria-label="Close pane"
|
||||
tabindex="0"
|
||||
data-bind="
|
||||
click: cancel, event: { keydown: onCloseKeyPress }"
|
||||
>
|
||||
<img src="../../../images/close-black.svg" title="Close" alt="Close" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload items header - End -->
|
||||
|
||||
<!-- Upload items errors - Start -->
|
||||
<div
|
||||
class="warningErrorContainer"
|
||||
aria-live="assertive"
|
||||
data-bind="visible: formErrors() && formErrors() !== ''"
|
||||
>
|
||||
<div class="warningErrorContent">
|
||||
<span><img class="paneErrorIcon" src="/error_red.svg" alt="Error"/></span>
|
||||
<span class="warningErrorDetailsLinkContainer">
|
||||
<span class="formErrors" data-bind="text: formErrors, attr: { title: formErrors }"></span>
|
||||
<a
|
||||
class="errorLink"
|
||||
role="link"
|
||||
data-bind="
|
||||
visible: formErrorsDetails() && formErrorsDetails() !== '',
|
||||
click: showErrorDetails"
|
||||
>More details</a
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Upload items errors - End -->
|
||||
|
||||
<!-- Upload item inputs - Start -->
|
||||
<div class="paneMainContent">
|
||||
<div>
|
||||
<div class="renewUploadItemsHeader">
|
||||
<span> Select JSON Files </span>
|
||||
<span class="infoTooltip" role="tooltip" tabindex="0">
|
||||
<img class="infoImg" src="/info-bubble.svg" alt="More information" />
|
||||
<span class="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
|
||||
class="importFilesTitle"
|
||||
type="text"
|
||||
disabled
|
||||
data-bind="value: 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"
|
||||
data-bind="event: { change: updateSelectedFiles }"
|
||||
/>
|
||||
<a
|
||||
href="#"
|
||||
id="fileImportLink"
|
||||
data-bind="event: { click: onImportLinkClick, keypress: onImportLinkKeyPress }"
|
||||
autofocus
|
||||
>
|
||||
<img
|
||||
class="fileImportImg"
|
||||
src="/folder_16x16.svg"
|
||||
alt="Select JSON files to upload"
|
||||
title="Select JSON files to upload"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div class="fileUploadSummaryContainer" data-bind="visible: uploadFileDataVisible">
|
||||
<b>File upload status</b>
|
||||
<table class="fileUploadSummary">
|
||||
<thead>
|
||||
<tr class="fileUploadSummaryHeader fileUploadSummaryTuple">
|
||||
<th>FILE NAME</th>
|
||||
<th>STATUS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- ko foreach: uploadFileData -->
|
||||
<tr class="fileUploadSummaryTuple">
|
||||
<td data-bind="text: $data.fileName"></td>
|
||||
<td data-bind="text: $parent.fileUploadSummaryText($data.numSucceeded, $data.numFailed)"></td>
|
||||
</tr>
|
||||
<!-- /ko -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paneFooter">
|
||||
<div class="leftpanel-okbut"><input type="submit" value="Upload" class="btncreatecoll1" /></div>
|
||||
</div>
|
||||
<!-- Upload item inputs - End -->
|
||||
</form>
|
||||
</div>
|
||||
<!-- Upload items form - Start -->
|
||||
<!-- Loader - Start -->
|
||||
<div class="dataExplorerLoaderContainer dataExplorerPaneLoaderContainer" data-bind="visible: isExecuting">
|
||||
<img class="dataExplorerLoader" src="/LoadingIndicator_3Squares.gif" />
|
||||
</div>
|
||||
<!-- Loader - End -->
|
||||
</div>
|
||||
</div>
|
||||
147
src/Explorer/Panes/UploadItemsPane.ts
Normal file
147
src/Explorer/Panes/UploadItemsPane.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import * as ko from "knockout";
|
||||
import * as Constants from "../../Common/Constants";
|
||||
import * as ViewModels from "../../Contracts/ViewModels";
|
||||
import { ContextualPaneBase } from "./ContextualPaneBase";
|
||||
import { ConsoleDataType } from "../Menus/NotificationConsole/NotificationConsoleComponent";
|
||||
import { NotificationConsoleUtils } from "../../Utils/NotificationConsoleUtils";
|
||||
import * as ErrorParserUtility from "../../Common/ErrorParserUtility";
|
||||
import { UploadDetailsRecord, UploadDetails } from "../../workers/upload/definitions";
|
||||
|
||||
const UPLOAD_FILE_SIZE_LIMIT = 2097152;
|
||||
|
||||
export class UploadItemsPane extends ContextualPaneBase {
|
||||
public selectedFilesTitle: ko.Observable<string>;
|
||||
public files: ko.Observable<FileList>;
|
||||
public uploadFileDataVisible: ko.Computed<boolean>;
|
||||
public uploadFileData: ko.ObservableArray<UploadDetailsRecord>;
|
||||
|
||||
constructor(options: ViewModels.PaneOptions) {
|
||||
super(options);
|
||||
this._initTitle();
|
||||
this.resetData();
|
||||
|
||||
this.selectedFilesTitle = ko.observable<string>("");
|
||||
this.uploadFileData = ko.observableArray<UploadDetailsRecord>();
|
||||
this.uploadFileDataVisible = ko.computed<boolean>(
|
||||
() => !!this.uploadFileData() && this.uploadFileData().length > 0
|
||||
);
|
||||
this.files = ko.observable<FileList>();
|
||||
this.files.subscribe((newFiles: FileList) => this._updateSelectedFilesTitle(newFiles));
|
||||
}
|
||||
|
||||
public submit() {
|
||||
this.formErrors("");
|
||||
if (!this.files() || this.files().length === 0) {
|
||||
this.formErrors("No files specified");
|
||||
this.formErrorsDetails("No files were specified. Please input at least one file.");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
"Could not upload items -- No files were specified. Please input at least one file."
|
||||
);
|
||||
return;
|
||||
} else if (this._totalFileSizeForFileList(this.files()) > UPLOAD_FILE_SIZE_LIMIT) {
|
||||
this.formErrors("Upload file size limit exceeded");
|
||||
this.formErrorsDetails("Total file upload size exceeds the 2 MB file size limit.");
|
||||
NotificationConsoleUtils.logConsoleMessage(
|
||||
ConsoleDataType.Error,
|
||||
"Could not upload items -- Total file upload size exceeds the 2 MB file size limit."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCollection: ViewModels.Collection = this.container.findSelectedCollection();
|
||||
this.isExecuting(true);
|
||||
selectedCollection &&
|
||||
selectedCollection
|
||||
.uploadFiles(this.files())
|
||||
.then(
|
||||
(uploadDetails: UploadDetails) => {
|
||||
this.uploadFileData(uploadDetails.data);
|
||||
this.files(undefined);
|
||||
this._resetFileInput();
|
||||
},
|
||||
(error: any) => {
|
||||
const message = ErrorParserUtility.parse(error);
|
||||
this.formErrors(message[0].message);
|
||||
this.formErrorsDetails(message[0].message);
|
||||
}
|
||||
)
|
||||
.finally(() => {
|
||||
this.isExecuting(false);
|
||||
});
|
||||
}
|
||||
|
||||
public updateSelectedFiles(element: any, event: any): void {
|
||||
this.files(event.target.files);
|
||||
}
|
||||
|
||||
public close() {
|
||||
super.close();
|
||||
this.resetData();
|
||||
this.files(undefined);
|
||||
this.uploadFileData([]);
|
||||
this._resetFileInput();
|
||||
}
|
||||
|
||||
public onImportLinkClick(source: any, event: MouseEvent): boolean {
|
||||
document.getElementById("importDocsInput").click();
|
||||
return false;
|
||||
}
|
||||
|
||||
public onImportLinkKeyPress = (source: any, event: KeyboardEvent): boolean => {
|
||||
if (event.keyCode === Constants.KeyCodes.Enter || event.keyCode === Constants.KeyCodes.Space) {
|
||||
this.onImportLinkClick(source, null);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
public fileUploadSummaryText = (numSucceeded: number, numFailed: number): string => {
|
||||
return `${numSucceeded} items created, ${numFailed} errors`;
|
||||
};
|
||||
|
||||
private _totalFileSizeForFileList(fileList: FileList): number {
|
||||
let totalFileSize: number = 0;
|
||||
if (!fileList) {
|
||||
return totalFileSize;
|
||||
}
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
totalFileSize = totalFileSize + fileList.item(i).size;
|
||||
}
|
||||
|
||||
return totalFileSize;
|
||||
}
|
||||
|
||||
private _updateSelectedFilesTitle(fileList: FileList) {
|
||||
this.selectedFilesTitle("");
|
||||
|
||||
if (!fileList || fileList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const originalTitle = this.selectedFilesTitle();
|
||||
this.selectedFilesTitle(originalTitle + `"${fileList.item(i).name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private _initTitle(): void {
|
||||
if (this.container.isPreferredApiCassandra() || this.container.isPreferredApiTable()) {
|
||||
this.title("Upload Tables");
|
||||
} else if (this.container.isPreferredApiGraph()) {
|
||||
this.title("Upload Graph");
|
||||
} else {
|
||||
this.title("Upload Items");
|
||||
}
|
||||
}
|
||||
|
||||
private _resetFileInput(): void {
|
||||
const inputElement = $("#importDocsInput");
|
||||
inputElement
|
||||
.wrap("<form>")
|
||||
.closest("form")
|
||||
.get(0)
|
||||
.reset();
|
||||
inputElement.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user