Edit with branch versioning

View on GitHub
Sample viewer app

Create, query, and edit a specific server version using a service geodatabase.

Image of edit with branch versioning 1 Image of edit with branch versioning 2

Use case

Workflows often progress in discrete stages, with each stage requiring the allocation of a different set of resources and business rules. Typically, each stage in the overall process represents a single unit of work, such as a work order or job. To manage these, you can create a separate, isolated version and modify it. Once this work is complete, you can integrate the changes into the default version.

How to use the sample

Upon launching the sample, you will be prompted to enter credentials for the service. Once loaded, the map will zoom to the extent of the feature layer. The current version is indicated at the top of the map. Tap "Create" to open a dialog to specify the version information (name, access, and description). See the Additional information section for the provided credentials and restrictions on the version name.

Create the version with the information that you specified. Select a feature to edit an attribute and/or tap a second time to relocate the point.

Tap "Switch" to switch between the version you created and the default version. Edits will automatically be applied to your version when switching to the default version.

How it works

  1. Create and load an AGSServiceGeodatabase with a feature service URL that has enabled version management.
  2. Get the AGSServiceFeatureTable from the service geodatabase.
  3. Create an AGSFeatureLayer from the service feature table.
  4. Create AGSServiceVersionParameters with a unique name, AGSVersionAccess, and description.

    • Note - See the additional information section for more restrictions on the version name.
  5. Create a new version calling AGSServiceGeodatabase.createVersion(with:completion:), passing in the service version parameters.
  6. Upon completion, get the AGSServiceVersionInfo of the version created.
  7. Switch to the version you have just created by calling AGSServiceGeodatabase.switchVersion(withName:completion:), passing in the version name obtained from the service version info from the step above.
  8. Select an AGSFeature to edit its "typdamage" attribute and location.
  9. Apply these edits to your version by calling AGSServiceGeodatabase.applyEdits(completion:).
  10. Switch back and forth between your versions and the default version to see how the versions differ.

Relevant API

  • AGSFeatureLayer
  • AGSServiceFeatureTable
  • AGSServiceGeodatabase
  • AGSServiceVersionInfo
  • AGSServiceVersionParameters
  • AGSVersionAccess

About the data

The feature service in this sample is Damage to commercial buildings located in Naperville, Illinois.

Additional information

The credentials for testing purposes:

  • username: editor01
  • password: S7#i2LWmYH75

The name of the version must meet the following criteria:

  1. Must not exceed 62 characters
  2. Must not include: Period (.), Semicolon (;), Single quotation mark ('), Double quotation mark (")
  3. Must not include a space for the first character
  4. Note - the version name will have the username and a period (.) prepended to it, e.g "editor01.MyNewUniqueVersionName".

Branch versioning access permissions:

  1. Public - Any portal user can view and edit the version.
  2. Protected - Any portal user can view, but only the version owner, feature layer owner, and portal administrator can edit the version.
  3. Private - Only the version owner, feature layer owner, and portal administrator can view and edit the version.

Tags

branch versioning, edit, version control, version management server

Sample Code

EditWithBranchVersioningViewController.swift
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
// Copyright 2020 Esri
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import UIKit
import ArcGIS

class EditWithBranchVersioningViewController: UIViewController {
    // MARK: Storyboard views

    /// The label to display branch versioning status.
    @IBOutlet var statusLabel: UILabel!
    /// The button to create a version.
    @IBOutlet var createBarButtonItem: UIBarButtonItem!
    /// The button to switch to a version.
    @IBOutlet var switchBarButtonItem: UIBarButtonItem!

    /// The map view managed by the view controller.
    @IBOutlet var mapView: AGSMapView! {
        didSet {
            mapView.map = AGSMap(basemapStyle: .arcGISStreets)
            mapView.touchDelegate = self
            mapView.callout.delegate = self
        }
    }

    // MARK: Properties

    /// The geodatabase's default branch version name.
    var defaultVersionName = ""
    /// The geodatabase's existing version names.
    /// - Note: To get a full list of versions, use `AGSServiceGeodatabase.fetchVersions(completion:)`.
    ///         In this sample, only the default version and versions created in current session are shown.
    var existingVersionNames = [String]() {
        didSet {
            switchBarButtonItem.isEnabled = existingVersionNames.count > 1
        }
    }
    /// The name of the branch that the user is currently on.
    var currentVersionName = "" {
        willSet(newValue) {
            // When the service has internal error, it might give empty version name string.
            if !newValue.isEmpty {
                setStatus(message: newValue)
            }
        }
    }

    /// The service geodatabase to demo branch versioning.
    var serviceGeodatabase: AGSServiceGeodatabase!
    /// A feature layer to display damaged building features.
    var featureLayer: AGSFeatureLayer!
    /// A reference to current selected feature.
    var selectedFeature: AGSFeature?
    /// A reference to the cancelable identify layer operation.
    var identifyOperation: AGSCancelable?

    /// Possible values for the service's "typdamage" attribute.
    private enum DamageType: String, CaseIterable {
        case destroyed = "Destroyed"
        case major = "Major"
        case minor = "Minor"
        case affected = "Affected"
        case inaccessible = "Inaccessible"
        case `default` = "Default"

        /// The human readable name of the DamageType.
        var title: String {
            switch self {
            case .destroyed: return "Destroyed"
            case .major: return "Major"
            case .minor: return "Minor"
            case .affected: return "Affected"
            case .inaccessible: return "Inaccessible"
            case .`default`: return "Default"
            }
        }
    }

    // MARK: Methods

    /// Load and set a service geodatabase from a feature service URL.
    ///
    /// - Parameter serviceURL: The URL to the feature service.
    func loadServiceGeodatabase(from serviceURL: URL) {
        let serviceGeodatabase = AGSServiceGeodatabase(url: serviceURL)
        // NOTE: Never hardcode login information in a production application. This is done solely for the sake of the sample.
        serviceGeodatabase.credential = AGSCredential(user: "editor01", password: "S7#i2LWmYH75")
        UIApplication.shared.showProgressHUD(message: "Loading service geodatabase…")
        serviceGeodatabase.load { [weak self] error in
            UIApplication.shared.hideProgressHUD()
            guard let self = self else { return }
            if let error = error {
                self.presentAlert(error: error)
                self.setStatus(message: "Error loading service geodatabase.")
            } else {
                self.serviceGeodatabase = serviceGeodatabase
                // Load with default version.
                self.defaultVersionName = serviceGeodatabase.defaultVersionName
                self.existingVersionNames.append(serviceGeodatabase.defaultVersionName)

                // Load feature layer.
                self.loadFeatureLayer(with: serviceGeodatabase.table(withLayerID: 0)!) { [weak self] featureLayer in
                    // After the feature layer is loaded, switch to default version.
                    self?.switchVersion(to: serviceGeodatabase.defaultVersionName)
                    self?.featureLayer = featureLayer
                    // Add the feature layer to the map.
                    self?.mapView.map?.operationalLayers.add(featureLayer)
                }
            }
        }
    }

    /// Load a feature layer with a feature table.
    ///
    /// - Parameters:
    ///   - featureTable: The feature table for creating the feature layer.
    ///   - completion: A closure that pass back an `AGSFeatureLayer` object after it is successfully loaded.
    func loadFeatureLayer(with featureTable: AGSFeatureTable, completion: @escaping (AGSFeatureLayer) -> Void) {
        let featureLayer = AGSFeatureLayer(featureTable: featureTable)

        UIApplication.shared.showProgressHUD(message: "Loading feature layer…")
        featureLayer.load { [weak self] error in
            UIApplication.shared.hideProgressHUD()
            if let extent = featureLayer.fullExtent {
                // Zoom to the target extent with animation.
                self?.mapView.setViewpoint(AGSViewpoint(targetExtent: extent), completion: nil)
                self?.createBarButtonItem.isEnabled = true
                completion(featureLayer)
            } else if let error = error {
                self?.presentAlert(error: error)
            }
        }
    }

    /// Identify a tapped point on a feature layer.
    ///
    /// - Parameters:
    ///   - featureLayer: The feature layer where to identify the features.
    ///   - screenPoint: The tapped screen point.
    ///   - completion: A closure to pass the identified feature for further usage.
    func identifyFeature(on featureLayer: AGSFeatureLayer, at screenPoint: CGPoint, completion: @escaping (AGSFeature) -> Void) {
        // Clear selection before identifying layers.
        clearSelection()
        // Clear in-progress identify operation.
        identifyOperation?.cancel()
        // Identify the tapped feature.
        identifyOperation = mapView.identifyLayer(featureLayer, screenPoint: screenPoint, tolerance: 10.0, returnPopupsOnly: false) { [weak self] identifyResult in
            guard let self = self else { return }
            guard !identifyResult.geoElements.isEmpty, let firstFeature = identifyResult.geoElements.first as? AGSFeature else {
                return
            }
            self.featureLayer.select(firstFeature)
            self.selectedFeature = firstFeature
            completion(firstFeature)
        }
    }

    /// Make a new branch version with parameters in the service geodatabase.
    ///
    /// - Parameters:
    ///   - geodatabase: The geodatabase to create the version.
    ///   - parameters: The parameters for the new branch version.
    ///   - completion: The results for `AGSServiceGeodatabase.createVersion(with:completion:)` call.
    func makeVersion(geodatabase: AGSServiceGeodatabase, with parameters: AGSServiceVersionParameters, completion: @escaping (Result<String, Error>) -> Void) {
        geodatabase.createVersion(with: parameters) { serviceVersionInfo, error in
            if let info = serviceVersionInfo {
                // Create version succeeded.
                completion(.success(info.name))
            } else if let error = error {
                // Failed to create version.
                completion(.failure(error))
            }
        }
    }

    /// Switch the geodatabase to connect to a new branch version.
    ///
    /// - Parameter branchVersionName: The new branch version name to connect to.
    func switchVersion(to branchVersionName: String) {
        if currentVersionName == defaultVersionName {
            // Discard local edits if currently on default branch.
            // Since making edits on default branch is disabled,
            // code below won't get executed, but left here for parity.
            undoLocalEdits(geodatabase: serviceGeodatabase) { [weak self] in
                self?.serviceGeodatabase.switchVersion(withName: branchVersionName) { error in
                    guard let self = self else { return }
                    if let error = error {
                        self.presentAlert(error: error)
                    } else {
                        DispatchQueue.main.async { [weak self] in self?.currentVersionName = branchVersionName }
                    }
                }
            }
        } else {
            // Apply local edits when switching to a user created branch.
            applyLocalEdits(geodatabase: serviceGeodatabase) { [weak self] in
                self?.serviceGeodatabase.switchVersion(withName: branchVersionName) { error in
                    guard let self = self else { return }
                    if let error = error {
                        self.presentAlert(error: error)
                    } else {
                        DispatchQueue.main.async { [weak self] in self?.currentVersionName = branchVersionName }
                    }
                }
            }
        }
    }

    /// Apply local edits to the geodatabase.
    ///
    /// - Parameters:
    ///   - geodatabase: The geodatabase to apply edits.
    ///   - completion: A closure to execute after edits are applied.
    func applyLocalEdits(geodatabase: AGSServiceGeodatabase, completion: @escaping () -> Void) {
        if geodatabase.hasLocalEdits() {
            UIApplication.shared.showProgressHUD(message: "Applying local edits…")
            geodatabase.applyEdits { _, _ in
                UIApplication.shared.hideProgressHUD()
                completion()
            }
        } else {
            completion()
        }
    }

    /// Undo local edits on the geodatabase.
    ///
    /// - Parameters:
    ///   - geodatabase: The geodatabase to discard edits.
    ///   - completion: A closure to execute after edits are undone.
    func undoLocalEdits(geodatabase: AGSServiceGeodatabase, completion: @escaping () -> Void) {
        if geodatabase.hasLocalEdits() {
            UIApplication.shared.showProgressHUD(message: "Discarding local edits…")
            geodatabase.undoLocalEdits { _ in
                UIApplication.shared.hideProgressHUD()
                completion()
            }
        } else {
            completion()
        }
    }

    // MARK: Actions

    @IBAction func createBarButtonItemTapped(_ sender: UIBarButtonItem) {
        // Clear selection before creating a new branch version.
        clearSelection()
        mapView.callout.dismiss()
        chooseVersionAccessPermission(sender) { permission in
            self.askUserForBranchDetails(permission: permission) { [weak self] parameters in
                guard let self = self else { return }
                self.makeVersion(geodatabase: self.serviceGeodatabase, with: parameters) { [weak self] result in
                    guard let self = self else { return }
                    switch result {
                    case .success(let versionName):
                        self.existingVersionNames.append(versionName)
                        // Switch to the new version after it is created.
                        self.switchVersion(to: versionName)
                    case .failure(let error):
                        self.presentAlert(error: error)
                        self.setStatus(message: "Error creating new version.")
                    }
                }
            }
        }
    }

    @IBAction func switchBarButtonItemTapped(_ sender: UIBarButtonItem) {
        // Clear selection before switching to a new branch version.
        clearSelection()
        mapView.callout.dismiss()
        chooseVersion(sender) { versionName in
            self.switchVersion(to: versionName)
        }
    }

    // MARK: UI

    func clearSelection() {
        guard let feature = selectedFeature else { return }
        featureLayer.unselectFeature(feature)
        selectedFeature = nil
    }

    func setStatus(message: String) {
        statusLabel.text = message
    }

    func showCallout(for feature: AGSFeature, tapLocation: AGSPoint?, isAccessoryButtonHidden: Bool = false) {
        let placeName = feature.attributes["placename"] as? String
        let damageName = feature.attributes["typdamage"] as? String ?? "Default"
        mapView.callout.title = damageName
        mapView.callout.detail = placeName
        mapView.callout.isAccessoryButtonHidden = isAccessoryButtonHidden
        mapView.callout.show(for: feature, tapLocation: tapLocation, animated: true)
    }

    /// Move the currently selected feature to the given map point, by updating the selected feature's geometry and feature table.
    func moveFeature(_ feature: AGSFeature, to mapPoint: AGSPoint) {
        // Create an alert to confirm that the user wants to update the geometry.
        let alert = UIAlertController(
            title: "Confirm Move",
            message: "Do you want to move the selected feature?",
            preferredStyle: .alert
        )
        // Clear the selection and selected feature on cancel.
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
            self.clearSelection()
        }
        // Move the feature to a new point geometry.
        let moveAction = UIAlertAction(title: "Move", style: .default) { _ in
            // Set the selected feature's geometry to the new map point.
            feature.geometry = mapPoint
            // Update the selected feature's feature table.
            feature.featureTable?.update(feature) { [weak self] _ in
                self?.clearSelection()
            }
        }
        alert.addAction(cancelAction)
        alert.addAction(moveAction)
        alert.preferredAction = moveAction
        present(alert, animated: true)
    }

    func askUserForBranchDetails(permission: AGSVersionAccess, completion: @escaping (AGSServiceVersionParameters) -> Void) {
        // Create an object to observe changes from the text fields.
        var textFieldObserver: NSObjectProtocol!
        // An alert to get user input for branch name and description.
        let alertController = UIAlertController(
            title: "Create Branch Version",
            message: "Please provide a branch name and a description.",
            preferredStyle: .alert
        )
        // Remove observer on cancel.
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
            NotificationCenter.default.removeObserver(textFieldObserver!)
        }
        // Create a new version and remove observer.
        let createAction = UIAlertAction(title: "Create", style: .default) { _ in
            NotificationCenter.default.removeObserver(textFieldObserver!)
            let branchText = alertController.textFields![0].text!.trimmingCharacters(in: .whitespacesAndNewlines)
            let descriptionText = alertController.textFields![1].text?.trimmingCharacters(in: .whitespacesAndNewlines)
            // Make service parameters with provided information.
            let parameters = AGSServiceVersionParameters()
            parameters.access = permission
            parameters.name = branchText
            if let description = descriptionText {
                parameters.parametersDescription = description
            }
            completion(parameters)
        }
        createAction.isEnabled = false
        alertController.addAction(cancelAction)
        alertController.addAction(createAction)
        alertController.preferredAction = createAction

        // The text field for version name.
        alertController.addTextField { textField in
            textField.placeholder = "Version name must be unique"
            textField.delegate = self
            textFieldObserver = NotificationCenter.default.addObserver(
                forName: UITextField.textDidChangeNotification,
                object: textField,
                queue: .main
            ) { [ unowned createAction ] _ in
                let text = textField.text!.trimmingCharacters(in: .whitespacesAndNewlines)
                // Enable the done button if branch version textfield is not empty.
                createAction.isEnabled = !text.isEmpty
            }
        }
        // The text field for version description.
        alertController.addTextField { textField in
            textField.placeholder = "Branch version description here"
        }
        present(alertController, animated: true)
    }

    func editFeatureDamageAttribute(feature: AGSFeature) {
        let alertController = UIAlertController(
            title: "Damage Type",
            message: "Choose a damage type for the building.",
            preferredStyle: .actionSheet
        )
        DamageType.allCases.forEach { type in
            let action = UIAlertAction(title: type.title, style: .default) { _ in
                feature.attributes["typdamage"] = type.rawValue
                feature.featureTable?.update(feature)
                self.mapView.callout.dismiss()
            }
            alertController.addAction(action)
        }
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) { _ in
            self.mapView.callout.dismiss()
        }
        alertController.addAction(cancelAction)
        alertController.popoverPresentationController?.sourceView = mapView
        let point = feature.geometry?.extent.center
        let sourcePoint = mapView.location(toScreen: point!)
        let sourceRect = CGRect(origin: sourcePoint, size: .zero)
        alertController.popoverPresentationController?.sourceRect = sourceRect
        present(alertController, animated: true)
    }

    func chooseVersion(_ sender: UIBarButtonItem, completion: @escaping (String) -> Void) {
        let alertController = UIAlertController(
            title: "Versions",
            message: "Choose to switch to another version.",
            preferredStyle: .actionSheet
        )
        existingVersionNames.forEach { name in
            let action = UIAlertAction(title: name, style: .default) { _ in
                completion(name)
            }
            alertController.addAction(action)
        }
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
        alertController.addAction(cancelAction)
        alertController.popoverPresentationController?.barButtonItem = sender
        present(alertController, animated: true)
    }

    func chooseVersionAccessPermission(_ sender: UIBarButtonItem, completion: @escaping (AGSVersionAccess) -> Void) {
        let alertController = UIAlertController(
            title: "Access Permissions",
            message: "Choose an access level for the new branch version.",
            preferredStyle: .actionSheet
        )
        let versionAccessPermission: [AGSVersionAccess] = [.public, .protected, .private]
        versionAccessPermission.forEach { versionAccess in
            let action = UIAlertAction(title: versionAccess.title, style: .default) { _ in
                completion(versionAccess)
            }
            alertController.addAction(action)
        }
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
        alertController.addAction(cancelAction)
        alertController.popoverPresentationController?.barButtonItem = sender
        present(alertController, animated: true)
    }

    // MARK: UIViewController

    override func viewDidLoad() {
        super.viewDidLoad()
        // Add the source code button item to the right of navigation bar.
        (navigationItem.rightBarButtonItem as? SourceCodeBarButtonItem)?.filenames = ["EditWithBranchVersioningViewController"]
        // Load the service geodatabase.
        let damageFeatureService = URL(string: "https://sampleserver7.arcgisonline.com/server/rest/services/DamageAssessment/FeatureServer")!
        loadServiceGeodatabase(from: damageFeatureService)
    }
}

// MARK: - AGSGeoViewTouchDelegate

extension EditWithBranchVersioningViewController: AGSGeoViewTouchDelegate {
    func geoView(_ geoView: AGSGeoView, didTapAtScreenPoint screenPoint: CGPoint, mapPoint: AGSPoint) {
        // Dismiss any presenting callout.
        mapView.callout.dismiss()
        // Disable features editing on the default branch and only allow showing callout.
        if currentVersionName == defaultVersionName {
            // Tap to identify a pixel on the feature layer.
            identifyFeature(on: featureLayer, at: screenPoint) { [weak self] feature in
                // Show a callout without the accessory button.
                self?.showCallout(for: feature, tapLocation: mapPoint, isAccessoryButtonHidden: true)
            }
        } else {
            if let selectedFeature = selectedFeature {
                // If there is a feature selected already, tap elsewhere to move it.
                moveFeature(selectedFeature, to: mapPoint)
            } else {
                // Tap to identify a pixel on the feature layer.
                identifyFeature(on: featureLayer, at: screenPoint) { [weak self] feature in
                    self?.showCallout(for: feature, tapLocation: mapPoint)
                }
            }
        }
    }
}

// MARK: - AGSCalloutDelegate

extension EditWithBranchVersioningViewController: AGSCalloutDelegate {
    func didTapAccessoryButton(for callout: AGSCallout) {
        // Dismiss the callout.
        callout.dismiss()
        // Show editing options actionsheet.
        editFeatureDamageAttribute(feature: selectedFeature!)
    }
}

// MARK: - UITextFieldDelegate

extension EditWithBranchVersioningViewController: UITextFieldDelegate {
    func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
        let text = (textField.text! as NSString).replacingCharacters(in: range, with: string)
        // 1. Must not include special characters: . ; ' "
        let invalidCharacters = ".;'\""
        let noInvalidCharacters = CharacterSet(charactersIn: invalidCharacters).isDisjoint(with: CharacterSet(charactersIn: text))
        // 2. Branch version string does not exceed 62 characters.
        let noLongerThan62Characters = text.count <= 62
        return noInvalidCharacters && noLongerThan62Characters
    }
}

private extension AGSVersionAccess {
    /// The human readable name of the version access.
    var title: String {
        switch self {
        case .public: return "Public"
        case .protected: return "Protected"
        case .private: return "Private"
        @unknown default: return "Unknown"
        }
    }
}

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.