Leveling Up Static Web Apps With the CLI

Published 06-01-2021 07:12 PM 2,221 Views
Microsoft

With the Azure Static Web Apps GA there was a sneaky little project that my colleague Wassim Chegham dropped, the Static Web Apps CLI.

The SWA CLI is a tool he’s been building for a while with the aim to make it easier to do local development, especially if you want to do an authenticated experience. I’ve been helping out on making sure it works on Windows and for Blazor/.NET apps.

It works by running as a proxy server in front of the web and API components, giving you a single endpoint that you access the site via, much like when it’s deployed to Azure. It also will inject a mock auth token if want to create an authenticated experience, and enforce the routing rules that are defined in the staticwebapp.config.json file. By default, it’ll want to serve static content from a folder, but my preference is to proxy the dev server from create-react-app, so I can get hot reloading and stuff working. Let’s take a look at how we can do that.

Using the cli with VS Code

With VS Code being my editor of choice, I wanted to work out the best way to work with it and the SWA CLI, so I can run a task and have it started. But as I prefer to use it as a proxy, this really requires me to run three tasks, one of the web app, one for the API and one for the CLI.

So, let’s start creating a tasks.json file:

 

{
    "version": "2.0.0",
    "tasks": [
        {
            "type": "npm",
            "script": "start",
            "label": "npm: start",
            "detail": "react-scripts start",
            "isBackground": true
        },
        {
            "type": "npm",
            "script": "start",
            "path": "api/",
            "label": "npm: start - api",
            "detail": "npm-run-all --parallel start:host watch",
            "isBackground": true
        },
        {
            "type": "shell",
            "command": "swa start http://localhost:3000 --api http://localhost:7071",
            "dependsOn": ["npm: start", "npm: start - api"],
            "label": "swa start",
            "problemMatcher": [],
            "dependsOrder": "parallel"
        }
    ]
}

 

The first two tasks will run npm start against the respective parts of the app, and you can see from the detail field what they are running. Both of these will run in the background of the shell (don’t need it to pop up to the foreground) but there’s a catch, they are running persistent commands, commands that don’t end and this has a problem.

When we want to run swa start, it’ll kick off the two other tasks but using dependent tasks in VS Code means it will wait until the task(s) in the dependsOn are completed. Now, this is fine if you run a task that has an end (like tsc), but if you’ve got a watch going (tsc -w), well, it’s not ending and the parent task can’t start.

Unblocking blocking processes

We need to run two blocking processes but trick VS Code into thinking they are completed so we can run the CLI. It turns out we can do that by customising the problemMatcher part of our task with a background section. The important part here is defining some endPattern regex’s. Let’s start with the web app, which in this case is going to be using create-react-app, and the last message it prints once the server is up and running is:

To create a production build, use npm run build.

Great, we’ll look for that in the output, and if it’s found, treat it as the command is done.

The API is a little trickier though, as it’s running two commands, func start and tsc -w, and it’s doing that in parallel, making our output stream a bit messy. We’re mostly interested on when the Azure Functions have started up, and if we look at the output the easiest message to regex is probably:

For detailed output, run func with –verbose flag.

It’s not the last thing that’s output, but it’s close to and appears after the Functions are running, so that’ll do.

Now that we know what to look for, let’s configure the problem matcher.

Updating our problem matchers

To do what we need to do we’re going to need to add a problemMatcher section to the task and it’ll need to implement a full problemMatcher. Here’s the updated task for the web app:

 

{
    "type": "npm",
    "script": "start",
    "problemMatcher": {
        "owner": "custom",
        "pattern": {
            "regexp": "^([^\\s].*)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$",
            "file": 1,
            "location": 2,
            "severity": 3,
            "code": 4,
            "message": 5
        },
        "fileLocation": "relative",
        "background": {
            "activeOnStart": true,
            "beginsPattern": "^\\.*",
            "endsPattern": "^\\.*To create a production build, use npm run build\\."
        }
    },
    "label": "npm: start",
    "detail": "react-scripts start",
    "isBackground": true
}

 

Since create-react-app doesn’t have a standard problemMatcher in VS Code (as far as I can tell anyway) we’re going to set the owner as custom and then use the TypeScript pattern (which I shamelessly stole from the docs :rolling_on_the_floor_laughing:). You might need to tweak the regex to get the VS Code problems list to work properly, but this will do for now. With our basic problemMatcher defined, we can add a background section to it and specify the endsPattern to match the string we’re looking for. You’ll also have to provide a beginsPattern, to which I’m lazy and just matching on anything.

Let’s do a similar thing for the API task:

 

{
    "type": "npm",
    "script": "start",
    "path": "api/",
    "problemMatcher": {
        "owner": "typescript",
        "pattern": {
            "regexp": "^([^\\s].*)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$",
            "file": 1,
            "location": 2,
            "severity": 3,
            "code": 4,
            "message": 5
        },
        "background": {
            "activeOnStart": true,
            "beginsPattern": "^\\.*",
            "endsPattern": ".*For detailed output, run func with --verbose flag\\..*"
        }
    },
    "label": "npm: start - api",
    "detail": "npm-run-all --parallel start:host watch",
    "isBackground": true
}

 

Now, we can run the swa start task and everything will launch for us!

 

2021-05-25-leveling-up-static-web-apps-with-the-cli.gif

Conclusion

Azure Static Web Apps just keeps getting better and better. With the CLI, it’s super easy to run a local environment and not have to worry about things like CORS, making it closer to how the deployed app operates. And combining it with these VS Code tasks means that with a few key presses you can get it up and running.

I’ve added these tasks to the GitHub repo of my Auth0 demo app from the post on using Auth0 with Static Web Apps.

%3CLINGO-SUB%20id%3D%22lingo-sub-2406567%22%20slang%3D%22en-US%22%3ELeveling%20Up%20Static%20Web%20Apps%20With%20the%20CLI%3C%2FLINGO-SUB%3E%3CLINGO-BODY%20id%3D%22lingo-body-2406567%22%20slang%3D%22en-US%22%3E%3CP%3EWith%20the%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fdocs.microsoft.com%2Fazure%2Fstatic-web-apps%3FWT.mc_id%3Djavascript-29580-aapowell%22%20rel%3D%22noopener%20noreferrer%22%20target%3D%22_blank%22%3EAzure%20Static%20Web%20Apps%3C%2FA%3E%26nbsp%3BGA%20there%20was%20a%20sneaky%20little%20project%20that%20my%20colleague%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Ftwitter.com%2Fmanekinekko%22%20rel%3D%22nofollow%20noopener%20noreferrer%22%20target%3D%22_blank%22%3EWassim%20Chegham%3C%2FA%3E%26nbsp%3Bdropped%2C%20the%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fgithub.com%2Fazure%2Fstatic-web-apps-cli%22%20rel%3D%22noopener%20noreferrer%22%20target%3D%22_blank%22%3EStatic%20Web%20Apps%20CLI%3C%2FA%3E.%3C%2FP%3E%3CP%3EThe%20SWA%20CLI%20is%20a%20tool%20he%E2%80%99s%20been%20building%20for%20a%20while%20with%20the%20aim%20to%20make%20it%20easier%20to%20do%20local%20development%2C%20especially%20if%20you%20want%20to%20do%20an%20authenticated%20experience.%20I%E2%80%99ve%20been%20helping%20out%20on%20making%20sure%20it%20works%20on%20Windows%20and%20for%20Blazor%2F.NET%20apps.%3C%2FP%3E%3CP%3EIt%20works%20by%20running%20as%20a%20proxy%20server%20in%20front%20of%20the%20web%20and%20API%20components%2C%20giving%20you%20a%20single%20endpoint%20that%20you%20access%20the%20site%20via%2C%20much%20like%20when%20it%E2%80%99s%20deployed%20to%20Azure.%20It%20also%20will%20inject%20a%20mock%20auth%20token%20if%20want%20to%20create%20an%20authenticated%20experience%2C%20and%20enforce%20the%20routing%20rules%20that%20are%20defined%20in%20the%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fdocs.microsoft.com%2Fazure%2Fstatic-web-apps%2Fconfiguration%3FWT.mc_id%3Djavascript-29580-aapowell%22%20rel%3D%22noopener%20noreferrer%22%20target%3D%22_blank%22%3Estaticwebapp.config.json%3C%2FA%3E%26nbsp%3Bfile.%20By%20default%2C%20it%E2%80%99ll%20want%20to%20serve%20static%20content%20from%20a%20folder%2C%20but%20my%20preference%20is%20to%20proxy%20the%20dev%20server%20from%26nbsp%3Bcreate-react-app%2C%20so%20I%20can%20get%20hot%20reloading%20and%20stuff%20working.%20Let%E2%80%99s%20take%20a%20look%20at%20how%20we%20can%20do%20that.%3C%2FP%3EUsing%20the%20cli%20with%20VS%20Code%3CP%3EWith%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fcode.visualstudio.com%2F%3FWT.mc_id%3Djavascript-29580-aapowell%22%20rel%3D%22noopener%20noreferrer%22%20target%3D%22_blank%22%3EVS%20Code%3C%2FA%3E%26nbsp%3Bbeing%20my%20editor%20of%20choice%2C%20I%20wanted%20to%20work%20out%20the%20best%20way%20to%20work%20with%20it%20and%20the%20SWA%20CLI%2C%20so%20I%20can%20run%20a%20task%20and%20have%20it%20started.%20But%20as%20I%20prefer%20to%20use%20it%20as%20a%20proxy%2C%20this%20really%20requires%20me%20to%20run%20three%20tasks%2C%20one%20of%20the%20web%20app%2C%20one%20for%20the%20API%20and%20one%20for%20the%20CLI.%3C%2FP%3E%3CP%3ESo%2C%20let%E2%80%99s%20start%20creating%20a%26nbsp%3Btasks.json%26nbsp%3Bfile%3A%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%7B%20%22version%22%3A%20%222.0.0%22%2C%20%22tasks%22%3A%20%5B%20%7B%20%22type%22%3A%20%22npm%22%2C%20%22script%22%3A%20%22start%22%2C%20%22label%22%3A%20%22npm%3A%20start%22%2C%20%22detail%22%3A%20%22react-scripts%20start%22%2C%20%22isBackground%22%3A%20true%20%7D%2C%20%7B%20%22type%22%3A%20%22npm%22%2C%20%22script%22%3A%20%22start%22%2C%20%22path%22%3A%20%22api%2F%22%2C%20%22label%22%3A%20%22npm%3A%20start%20-%20api%22%2C%20%22detail%22%3A%20%22npm-run-all%20--parallel%20start%3Ahost%20watch%22%2C%20%22isBackground%22%3A%20true%20%7D%2C%20%7B%20%22type%22%3A%20%22shell%22%2C%20%22command%22%3A%20%22swa%20start%20%3CA%20href%3D%22http%3A%2F%2Flocalhost%3A3000%22%20target%3D%22_blank%22%20rel%3D%22nofollow%20noopener%20noreferrer%22%3Ehttp%3A%2F%2Flocalhost%3A3000%3C%2FA%3E%20--api%20%3CA%20href%3D%22http%3A%2F%2Flocalhost%3A7071%22%20target%3D%22_blank%22%20rel%3D%22nofollow%20noopener%20noreferrer%22%3Ehttp%3A%2F%2Flocalhost%3A7071%3C%2FA%3E%22%2C%20%22dependsOn%22%3A%20%5B%22npm%3A%20start%22%2C%20%22npm%3A%20start%20-%20api%22%5D%2C%20%22label%22%3A%20%22swa%20start%22%2C%20%22problemMatcher%22%3A%20%5B%5D%2C%20%22dependsOrder%22%3A%20%22parallel%22%20%7D%20%5D%20%7D%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3EThe%20first%20two%20tasks%20will%20run%26nbsp%3Bnpm%20start%26nbsp%3Bagainst%20the%20respective%20parts%20of%20the%20app%2C%20and%20you%20can%20see%20from%20the%26nbsp%3Bdetail%26nbsp%3Bfield%20what%20they%20are%20running.%20Both%20of%20these%20will%20run%20in%20the%20background%20of%20the%20shell%20(don%E2%80%99t%20need%20it%20to%20pop%20up%20to%20the%20foreground)%20but%20there%E2%80%99s%20a%20catch%2C%20they%20are%20running%20persistent%20commands%2C%20commands%20that%20don%E2%80%99t%20end%20and%20this%20has%20a%20problem.%3C%2FP%3E%3CP%3EWhen%20we%20want%20to%20run%26nbsp%3Bswa%20start%2C%20it%E2%80%99ll%20kick%20off%20the%20two%20other%20tasks%20but%20using%20dependent%20tasks%20in%20VS%20Code%20means%20it%20will%20wait%20until%20the%20task(s)%20in%20the%26nbsp%3BdependsOn%26nbsp%3Bare%20completed.%20Now%2C%20this%20is%20fine%20if%20you%20run%20a%20task%20that%20has%20an%20end%20(like%26nbsp%3Btsc)%2C%20but%20if%20you%E2%80%99ve%20got%20a%20watch%20going%20(tsc%20-w)%2C%20well%2C%20it%E2%80%99s%20not%20ending%20and%20the%20parent%20task%20can%E2%80%99t%20start.%3C%2FP%3EUnblocking%20blocking%20processes%3CP%3EWe%20need%20to%20run%20two%20blocking%20processes%20but%20trick%20VS%20Code%20into%20thinking%20they%20are%20completed%20so%20we%20can%20run%20the%20CLI.%20It%20turns%20out%20we%20can%20do%20that%20by%20customising%20the%26nbsp%3BproblemMatcher%26nbsp%3Bpart%20of%20our%20task%20with%20a%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fcode.visualstudio.com%2Fdocs%2Feditor%2Ftasks%3FWT.mc_id%3Djavascript-29580-aapowell%23_background-watching-tasks%22%20rel%3D%22noopener%20noreferrer%22%20target%3D%22_blank%22%3Ebackground%26nbsp%3Bsection%3C%2FA%3E.%20The%20important%20part%20here%20is%20defining%20some%26nbsp%3BendPattern%26nbsp%3Bregex%E2%80%99s.%20Let%E2%80%99s%20start%20with%20the%20web%20app%2C%20which%20in%20this%20case%20is%20going%20to%20be%20using%26nbsp%3Bcreate-react-app%2C%20and%20the%20last%20message%20it%20prints%20once%20the%20server%20is%20up%20and%20running%20is%3A%3C%2FP%3E%20%3CP%3ETo%20create%20a%20production%20build%2C%20use%20npm%20run%20build.%3C%2FP%3E%3CP%3EGreat%2C%20we%E2%80%99ll%20look%20for%20that%20in%20the%20output%2C%20and%20if%20it%E2%80%99s%20found%2C%20treat%20it%20as%20the%20command%20is%26nbsp%3Bdone.%3C%2FP%3E%3CP%3EThe%20API%20is%20a%20little%20trickier%20though%2C%20as%20it%E2%80%99s%20running%20two%20commands%2C%26nbsp%3Bfunc%20start%26nbsp%3Band%26nbsp%3Btsc%20-w%2C%20and%20it%E2%80%99s%20doing%20that%20in%20parallel%2C%20making%20our%20output%20stream%20a%20bit%20messy.%20We%E2%80%99re%20mostly%20interested%20on%20when%20the%20Azure%20Functions%20have%20started%20up%2C%20and%20if%20we%20look%20at%20the%20output%20the%20easiest%20message%20to%20regex%20is%20probably%3A%3C%2FP%3E%20%3CP%3EFor%20detailed%20output%2C%20run%20func%20with%20%E2%80%93verbose%20flag.%3C%2FP%3E%3CP%3EIt%E2%80%99s%20not%20the%20last%20thing%20that%E2%80%99s%20output%2C%20but%20it%E2%80%99s%20close%20to%20and%20appears%20after%20the%20Functions%20are%20running%2C%20so%20that%E2%80%99ll%20do.%3C%2FP%3E%3CP%3ENow%20that%20we%20know%20what%20to%20look%20for%2C%20let%E2%80%99s%20configure%20the%20problem%20matcher.%3C%2FP%3EUpdating%20our%20problem%20matchers%3CP%3ETo%20do%20what%20we%20need%20to%20do%20we%E2%80%99re%20going%20to%20need%20to%20add%20a%26nbsp%3BproblemMatcher%26nbsp%3Bsection%20to%20the%20task%20and%20it%E2%80%99ll%20need%20to%20implement%20a%20full%26nbsp%3BproblemMatcher.%20Here%E2%80%99s%20the%20updated%20task%20for%20the%20web%20app%3A%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%7B%20%22type%22%3A%20%22npm%22%2C%20%22script%22%3A%20%22start%22%2C%20%22problemMatcher%22%3A%20%7B%20%22owner%22%3A%20%22custom%22%2C%20%22pattern%22%3A%20%7B%20%22regexp%22%3A%20%22%5E(%5B%5E%5C%5Cs%5D.*)%5C%5C((%5C%5Cd%2B%7C%5C%5Cd%2B%2C%5C%5Cd%2B%7C%5C%5Cd%2B%2C%5C%5Cd%2B%2C%5C%5Cd%2B%2C%5C%5Cd%2B)%5C%5C)%3A%5C%5Cs%2B(error%7Cwarning%7Cinfo)%5C%5Cs%2B(TS%5C%5Cd%2B)%5C%5Cs*%3A%5C%5Cs*(.*)%24%22%2C%20%22file%22%3A%201%2C%20%22location%22%3A%202%2C%20%22severity%22%3A%203%2C%20%22code%22%3A%204%2C%20%22message%22%3A%205%20%7D%2C%20%22fileLocation%22%3A%20%22relative%22%2C%20%22background%22%3A%20%7B%20%22activeOnStart%22%3A%20true%2C%20%22beginsPattern%22%3A%20%22%5E%5C%5C.*%22%2C%20%22endsPattern%22%3A%20%22%5E%5C%5C.*To%20create%20a%20production%20build%2C%20use%20npm%20run%20build%5C%5C.%22%20%7D%20%7D%2C%20%22label%22%3A%20%22npm%3A%20start%22%2C%20%22detail%22%3A%20%22react-scripts%20start%22%2C%20%22isBackground%22%3A%20true%20%7D%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3ESince%26nbsp%3Bcreate-react-app%26nbsp%3Bdoesn%E2%80%99t%20have%20a%20standard%26nbsp%3BproblemMatcher%26nbsp%3Bin%20VS%20Code%20(as%20far%20as%20I%20can%20tell%20anyway)%20we%E2%80%99re%20going%20to%20set%20the%26nbsp%3Bowner%26nbsp%3Bas%26nbsp%3Bcustom%26nbsp%3Band%20then%20use%20the%20TypeScript%26nbsp%3Bpattern%26nbsp%3B(which%20I%20shamelessly%20stole%20from%20the%20docs%20).%20You%20might%20need%20to%20tweak%20the%20regex%20to%20get%20the%20VS%20Code%20problems%20list%20to%20work%20properly%2C%20but%20this%20will%20do%20for%20now.%20With%20our%20basic%26nbsp%3BproblemMatcher%26nbsp%3Bdefined%2C%20we%20can%20add%20a%26nbsp%3Bbackground%26nbsp%3Bsection%20to%20it%20and%20specify%20the%26nbsp%3BendsPattern%26nbsp%3Bto%20match%20the%20string%20we%E2%80%99re%20looking%20for.%20You%E2%80%99ll%20also%20have%20to%20provide%20a%26nbsp%3BbeginsPattern%2C%20to%20which%20I%E2%80%99m%20lazy%20and%20just%20matching%20on%26nbsp%3Banything.%3C%2FP%3E%3CP%3ELet%E2%80%99s%20do%20a%20similar%20thing%20for%20the%20API%20task%3A%3C%2FP%3E%3CP%3E%26nbsp%3B%3C%2FP%3E%7B%20%22type%22%3A%20%22npm%22%2C%20%22script%22%3A%20%22start%22%2C%20%22path%22%3A%20%22api%2F%22%2C%20%22problemMatcher%22%3A%20%7B%20%22owner%22%3A%20%22typescript%22%2C%20%22pattern%22%3A%20%7B%20%22regexp%22%3A%20%22%5E(%5B%5E%5C%5Cs%5D.*)%5C%5C((%5C%5Cd%2B%7C%5C%5Cd%2B%2C%5C%5Cd%2B%7C%5C%5Cd%2B%2C%5C%5Cd%2B%2C%5C%5Cd%2B%2C%5C%5Cd%2B)%5C%5C)%3A%5C%5Cs%2B(error%7Cwarning%7Cinfo)%5C%5Cs%2B(TS%5C%5Cd%2B)%5C%5Cs*%3A%5C%5Cs*(.*)%24%22%2C%20%22file%22%3A%201%2C%20%22location%22%3A%202%2C%20%22severity%22%3A%203%2C%20%22code%22%3A%204%2C%20%22message%22%3A%205%20%7D%2C%20%22background%22%3A%20%7B%20%22activeOnStart%22%3A%20true%2C%20%22beginsPattern%22%3A%20%22%5E%5C%5C.*%22%2C%20%22endsPattern%22%3A%20%22.*For%20detailed%20output%2C%20run%20func%20with%20--verbose%20flag%5C%5C..*%22%20%7D%20%7D%2C%20%22label%22%3A%20%22npm%3A%20start%20-%20api%22%2C%20%22detail%22%3A%20%22npm-run-all%20--parallel%20start%3Ahost%20watch%22%2C%20%22isBackground%22%3A%20true%20%7D%3CP%3E%26nbsp%3B%3C%2FP%3E%3CP%3ENow%2C%20we%20can%20run%20the%26nbsp%3Bswa%20start%26nbsp%3Btask%20and%20everything%20will%20launch%20for%20us!%3C%2FP%3E%20%26nbsp%3B%3CP%3E%3C%2FP%3EConclusion%3CP%3EAzure%20Static%20Web%20Apps%20just%20keeps%20getting%20better%20and%20better.%20With%20the%20CLI%2C%20it%E2%80%99s%20super%20easy%20to%20run%20a%20local%20environment%20and%20not%20have%20to%20worry%20about%20things%20like%20CORS%2C%20making%20it%20closer%20to%20how%20the%20deployed%20app%20operates.%20And%20combining%20it%20with%20these%20VS%20Code%20tasks%20means%20that%20with%20a%20few%20key%20presses%20you%20can%20get%20it%20up%20and%20running.%3C%2FP%3E%3CP%3EI%E2%80%99ve%20added%20these%20tasks%20to%20the%20GitHub%20repo%20of%20my%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fgithub.com%2Faaronpowell%2Fswa-custom-auth-auth0%22%20rel%3D%22noopener%20noreferrer%22%20target%3D%22_blank%22%3EAuth0%20demo%20app%3C%2FA%3E%26nbsp%3Bfrom%20the%20post%20on%20using%26nbsp%3B%3CA%20href%3D%22https%3A%2F%2Fwww.aaron-powell.com%2Fposts%2F2021-05-13-using-auth0-with-static-web-apps%2F%22%20rel%3D%22nofollow%20noopener%20noreferrer%22%20target%3D%22_blank%22%3EAuth0%20with%20Static%20Web%20Apps%3C%2FA%3E.%3C%2FP%3E%3C%2FLINGO-BODY%3E%3CLINGO-TEASER%20id%3D%22lingo-teaser-2406567%22%20slang%3D%22en-US%22%3E%3CP%3ELet's%20check%20out%20the%20Azure%20Static%20Web%20Apps%20CLI%20and%20how%20to%20use%20it%20with%20VS%20Code%3C%2FP%3E%3C%2FLINGO-TEASER%3E%3CLINGO-LABS%20id%3D%22lingo-labs-2406567%22%20slang%3D%22en-US%22%3E%3CLINGO-LABEL%3EAzure%20Functions%3C%2FLINGO-LABEL%3E%3CLINGO-LABEL%3EWeb%20Apps%3C%2FLINGO-LABEL%3E%3C%2FLINGO-LABS%3E
Co-Authors
Version history
Last update:
‎Jun 01 2021 07:12 PM
Updated by: