# Pinksky - Fuld indhold > Pinksky er et dansk Microsoft 365 og Power Platform konsulenthus. Vi leverer skalerbare IT-løsninger til virksomheder i hele Danmark og er baseret i Herning, Midtjylland. Pinksky bygger driftssikre IT-løsninger, der holder i længden, og skalerer med jeres virksomhed - fordi vi er "in IT together" og tager ansvar hele vejen. Kontakt: hej@pinksky.dk | +45 77 11 11 77 Adresse: Teglvænget 29, Studio 1-2, 7400 Herning, Danmark CVR: 46452135 Sprog: Dansk Genereret: 2026-10-14 --- ## Tjenester ### AI og Microsoft Copilot URL: https://pinksky.dk/tjenester/ai/ Beskrivelse: AI løsninger og implementeringer på Microsoft platformen. Det er ikke til at komme uden om - AI er kommet for at blive. AI giver et stort potentiale i at optimere og automatisere dine forretningsprocesser. Hos Pinksky har vi ikke kun erfaring med at implementere AI-løsninger, men også med at sikre, at de skabes med mennesket i centrum. ## AI integrationer Microsoft har integreret AI i en stor del af deres platform, hvilket giver god mulighed for at anvende AI teknologier til at løfte virksomhedens forretning og processer. Det gælder både indenfor platformen og udenfor platformen. AI kan også hjælpe med at bringe tredjeparts systemer tættere sammen. Man kan bruge AI til at udstille sin vidensbase som en chatbot på sin hjemmeside. Eller gøre sine ikke-Microsoft-systemer let tilgængelige direkte i Microsoft-platformen - fra at kunne spørge Copilot om lagerstatus på sine produkter til at integrere sit kørselsregistreringssystem med Copilot. ## AI der skaber værdi Vi ser ofte i forbindelse med AI implementeringer, at de fleste starter for stort og tager munden for fuld - og ender med løsninger, der hverken skaber værdi for medarbejderne eller virksomheden. Hos Pinksky arbejder vi altid med udgangspunkt i at skabe værdi for både virksomheden og medarbejderne. Vi starter først med at identificere og analysere virksomhedens forretningsprocesser, for at finde de områder hvor AI kan skabe en reel værdi. Herefter arbejder vi tæt sammen om at designe og implementere AI løsninger der er skræddersyet til virksomheden og opgaven der skal løses - Det sikrer at både medarbejderne og virksomheden får mest muligt ud af teknologien. AI skaber størst værdi, når det bruges til konkrete opgaver, hvor det gør en reel forskel i hverdagen - og hvor løsningen giver mening for både virksomheden og de medarbejdere, der skal arbejde med den. ## Det rigtige AI-værktøj til opgaven Microsofts AI-landskab spænder bredt, og en stor del af vores arbejde er at vælge det rigtige niveau til opgaven: - **Microsoft 365 Copilot** - Schweizerkniven, når medarbejderne skal have en generel assistent i de værktøjer, de allerede arbejder i. Kræver primært, at data og rettigheder er på plads. - **Copilot in SharePoint** - når AI skal hjælpe med at skabe struktur og overblik i virksomhedens dokumenter, fx at opsummere indholdet af en lang rapport, eller at finde relevant viden i virksomhedens dokumenter. - **Agenter i Copilot Studio** - når AI skal løse en afgrænset opgave: svare på spørgsmål fra en bestemt vidensbase, guide medarbejdere gennem en proces, eller slå op i et fagsystem. Eller helt simple "automations" der kan hjælpe med at automatisere en proces, fx support-emails der skal formuleres et svar på. - **Azure AI** - når behovet er specialiseret: dokumentanalyse i stor skala, billedgenkendelse, integration af AI i egne applikationer, eller krav om fuld kontrol over modeller og datastrømme. Det billigste valg er sjældent at starte med det mest avancerede - men at vælge det mindste værktøj, der løser opgaven, og bygge videre derfra. ## Hvad kan AI bruges til på Microsoft platformen? Meget mere end en chatbot. AI kan opsummere og finde viden i jeres dokumenter, automatisere behandlingen af henvendelser, udtrække data fra bilag og kontrakter, og gøre tredjepartssystemer tilgængelige direkte i Copilot - eksempelvis at spørge om lagerstatus på et produkt, anmode om ferie, eller noget helt tredje uden at forlade Teams. --- ## Hvad er forskellen på Microsoft 365 Copilot og en skræddersyet AI-løsning? Microsoft 365 Copilot er en færdig assistent, der arbejder i Word, Outlook, Teams og resten af Microsoft 365, baseret på jeres egne data. En skræddersyet løsning - eksempelvis en agent bygget i Copilot Studio eller på Azure AI - er derimod målrettet én konkret opgave eller proces. Ofte er den rigtige kombination afhængig af medarbejderens arbejdsopgaver, og hvad de har behov for at udføre, ikke alle medarbejdere har behov for en "fuld" Microsoft 365 Copilot, men kan nøjes med en agent, der hjælper dem med at udføre deres specifikke opgaver. --- ## Er vores data sikre, når vi bruger AI? Med Microsofts AI-værktøjer forbliver jeres data i jeres eget miljø, respekterer de adgangsrettigheder I allerede har opsat, og bruges ikke til at træne offentlige modeller. Forudsætningen er, at rettigheder og governance er på plads - og det er netop der, vi altid starter. --- ## Hvordan kommer vi i gang med AI? Start småt og konkret. Vi hjælper med at identificere de processer, hvor AI kan skabe reel værdi, og bygger derefter en løsning målrettet opgaven - i stedet for at starte for stort og ende med en løsning, ingen bruger. [Kontakt os](/kontakt/) for en snak om mulighederne. --- ### Dynamics 365 Sales - CRM URL: https://pinksky.dk/tjenester/dynamics-365-sales/ Beskrivelse: Optimering af salgsprocesser med Microsoft Dynamics 365 Sales. De fleste organisationer har en grad af salg, uanset om det er B2B eller B2C. Det er ofte de samme problemstillinger der går igen: Hvem har styr på hvilke salgsmuligheder? Hvordan følger man op på dem? Og hvordan sikrer man, at ingen salgsmuligheder går tabt? ## Optimering af salgsmuligheder Med et CRM-system sikrer du kontrollen over virksomhedens kundeemner og salgsmuligheder, både på tværs af salgsteams, geografiske områder og salgskanaler. Det er vigtigt at have styr på i hvilken fase dine salgsmuligheder er, og hvordan de bevæger sig gennem salgstragten. På den måde kan du optimere dine salgsindsatser og maksimere konverteringsraten. Ved at registrere både dine salgsmuligheder, kundeemner, kontaktpersoner og kunder, skaber du et samlet overblik. Det hjælper virksomheden med at holde styr på sin pipeline, og samtidig bevarer overblikket, hvis en medarbejder er fraværende. ## Dynamics 365 Sales vs. Model-driven apps Microsoft tilbyder muligheden for at tilkøbe sig et licensbaseret CRM produkt, også kaldet "Microsoft Dynamics 365 Sales" (tidligere kendt under "Customer Engagement") Systemet favner en bred vifte af muligheder, men er samtidig bygget til at understøtte virksomheder med flere 100.000 ansatte. Mange virksomheder i Danmark kan godt blive overvældet over mængden af muligheder i systemet, og fravælger typisk en lang række egenskaber, men som man stadig betaler for under licensprisen. Hos Pinksky hjælper vi dig gerne med at opnå det fulde potentiale af din Dynamics 365 Sales løsning, men det er værd at bemærke at Dynamics 365 Sales i bund og grund blot er en Model-driven app. Ved at tage udgangspunkt i Microsoft Dataverse, og bygge sin egen løsning kan man opnå en betydelig lavere licensomkostning. Hos Pinksky hjælper vi med at designe og opbygge en CRM løsning baseret på Dataverse og Model-driven apps. Det sikrer at I får en løsning der passer til jeres forretning - uden at betale for funktioner, I ikke bruger. ## Det får I med en CRM-løsning fra Pinksky Uanset om løsningen ender som Dynamics 365 Sales eller en skræddersyet Dataverse-løsning, bygger vi den omkring sælgernes hverdag: - **Ét samlet billede af kunden** - kontakter, aftaler, mails og dokumenter samlet på kunden, i stedet for spredt i personlige indbakker, og post-its på skærmen. - **Opfølgning der ikke glipper** - aktiviteter og påmindelser sørger for, at ingen salgsmulighed går tabt på grund af glemsomhed. - **Arbejd der hvor sælgerne er** - tilgængeligt alle steder, om det er på PC, tablet eller mobil, uden at miste funktionalitet. - **Ledelsesoverblik** - dashboards over pipeline og aktiviteter, der gør salgsmøderne datadrevne i stedet for "bare en mavefornemmelse". - **AI-Ready** - med Microsofts AI-værktøjer kan I få hjælp til at minimere manuel dataindtastning, ét tryk på en knap, eller et billede af visitkortet og kontakten er oprettet, ikke flere manuelle tastefejl. ## Sådan implementerer vi En CRM-implementering lykkes eller fejler på, om sælgerne tager systemet til sig, og føler sig "løftet". Derfor starter vi ikke med teknikken: 1. **Salgsprocessen først** - vi kortlægger, hvordan I reelt sælger: faserne, overdragelserne og de informationer, der betyder noget. 2. **Konfiguration** - systemet sættes op, så det afspejler processen - ikke omvendt. Fokus på simpelhed, brugervenlighed og relevans for sælgerne. 3. **Integrationer** - kobling til de systemer, der giver mening: økonomisystem, marketing eller [jeres øvrige platforme](/tjenester/integrationer/). 4. **Træning og forankring** - sælgerne trænes i deres egen proces, ikke i et generisk system - og vi følger op, når hverdagen har meldt sig. ## Hvad er Dynamics 365 Sales? Dynamics 365 Sales er Microsofts CRM-system til salgsstyring. Det holder styr på kundeemner, kontaktpersoner og salgsmuligheder, og giver overblik over hele salgstragten - på tværs af salgsteams, geografiske områder og kanaler. --- ## Kan Dynamics 365 Sales tilpasses vores salgsproces? Ja - Microsoft stiller en række byggeklodser til rådighed, som vi sammen kan tilpasse. Faser, felter, visninger og automatiseringer kan tilpasses den måde, I faktisk sælger på. En standardopsætning, der ikke matcher salgsprocessen, er den hyppigste årsag til, at sælgerne ikke bruger CRM-systemet. --- ## Er der et billigere alternativ til Dynamics 365 Sales? Ofte, ja. Dynamics 365 Sales er i bund og grund en Model-driven app bygget på Dataverse. Har I ikke behov for hele funktionspaletten, kan vi bygge en skræddersyet CRM-løsning direkte på Dataverse - med en betydeligt lavere licensomkostning, og uden funktioner I aldrig bruger, men stadig betaler for. Det kommer dog med sine egne begrænsninger, og vi hjælper jer med at vurdere, hvad der giver mest mening for jeres virksomhed. --- ## Hvordan kommer vi i gang? Vi starter med at forstå jeres salgsproces og behov, og anbefaler derefter den rigtige vej - hvad enten det er Dynamics 365 Sales eller en Dataverse-baseret løsning. [Kontakt os](/kontakt/) for en uforpligtende snak. --- ### Integrationer URL: https://pinksky.dk/tjenester/integrationer/ Beskrivelse: Udvidelser, integrationer og skræddersyede løsninger på Microsoft platformen. Hos Pinksky ved vi at der ikke findes to virksomheder der er ens, hverken i værdisæt, go-to market strategi, identitet eller i systemlandskab. Og vi ved hvor uoverskueligt det kan synes at navigere i et væld af systemer og platforme. Men vi har mødt lidt af hvert efterhånden, og vi har en solid erfaring med at bygge bro mellem systemer, og skabe sammenhæng i komplekse landskaber. ## Sådan arbejder vi med integrationer Et godt integrationsforløb starter med at forstå systemlandskabet. Vi kortlægger hvilke systemer der skal tale sammen, hvilke data der skal udveksles, og hvilke forretningsregler der styrer flowet. Derefter vælger vi den rette tilgang, ud fra de tilgængelige snitflader, om det er et REST-API, webhooks, filbaserede overførsler, eller noget helt tredje - og bygger en løsning der er robust, nem at vedligeholde, og med mulighed for overvågning, så ingen integrationer pludseligt har stået stille i 14 dage. Vi arbejder tæt på Microsoft-platformen og trækker på Power Platformen, Azure, og "pro-code", men vi er ikke låste: det rigtige værktøj vælges ud fra jeres krav, ikke vores foretrukne teknologier. ## Realtid eller batch? Typiske integrationsmønstre Integrationer kommer i flere former, og valget af mønster betyder mere end valget af teknologi: - **Realtid (event-drevet)** - data flyder i samme øjeblik, noget sker: Ordren oprettes i ERP-systemet, sekundet efter kunden har bestilt. Vigtigt, når brugere eller kunder venter på resultatet. - **Batch (planlagt)** - data overføres samlet med faste intervaller, eksempelvis hver nat. Enklere og billigere, og rigeligt til eksempelvis rapportering eller stamdata, der sjældent ændrer sig. - **Punkt-til-punkt eller hub** - to systemer kan tale direkte sammen, men når antallet af integrationer vokser, samler vi dem ofte i et fælles mønster, så systemer kan kobles på eller fra uden at starte forfra. Det rigtige svar afhænger af data, systemer og budget - og ofte er den bedste løsning en kombination. ## Vores erfaring Vores dygtige konsulenter har igennem årene arbejdet med en bred vifte af integrationer, nedenfor finder du et udpluk af de systemer de tidligere har integreret med, listen er ikke komplet, og det er ikke et udtryk for at vi har en standardløsning at tage ned fra hylden, men vi har ét solidt erfaringsgrundlag. ## Hvad er systemintegration? Systemintegration er at få virksomhedens forskellige systemer til at udveksle data automatisk - eksempelvis at ordrer fra webshoppen lander i ERP-systemet, eller at medarbejderdata fra HR-systemet automatisk opretter brugere i Microsoft 365. Målet er at slippe for manuelle indtastninger, dobbeltarbejde og fejl. Og altid at have **én version af sandheden** - når data ændres ét sted, slår ændringen igennem alle steder. --- ## Hvilke systemer kan I integrere med? Stort set alle systemer med en snitflade - REST-API'er, webhooks, filbaserede overførsler eller noget helt tredje. Vores konsulenter har gennem årene integreret med en bred vifte af ERP-, HR- og økonomisystemer samt offentlige data-API'er - du kan se et udpluk i listen ovenfor. --- ## Hvad sker der, hvis integrationen fejler? Selvom IT er pålideligt, sker der fejl, nedbrud eller systemopdateringer - netop derfor anbefaler vi altid at bygge overvågning ind i vores integrationer. I får besked, når noget fejler, i stedet for at opdage det ved et tilfælde efter 14 dage. Robusthed og vedligeholdelsesvenlighed er en del af designet fra starten. --- ## Hvad koster en integration? Det afhænger af systemernes snitflader og kompleksiteten af forretningsreglerne. En simpel envejs-overførsel er en lille opgave; en realtidsintegration med komplekse regler er et projekt. [Kontakt os](/kontakt/) med jeres systemlandskab, så giver vi et konkret bud. --- ### Power BI - dataanalyse og visualisering URL: https://pinksky.dk/tjenester/power-bi/ Beskrivelse: Power BI løsninger og implementeringer på Microsoft platformen. "Data is king" er et velkendt udtryk, og din forretning har sikkert allerede en masse data - men får du det maksimale udbytte ud af dem? Med Microsoft Power BI kan du få indsigt i dine data og træffe datadrevne beslutninger, der kan optimere din forretning. Ved at benytte Power BI får du mulighed for et bedre overblik over dine data, og dykke ned i detaljerne. Det hjælper med at give en bedre forståelse for din forretning, og benytte det fulde potentiale af virksomhedens data. ## Hvorfor Power BI? Med Power BI kan du samle data på tværs af virksomhedens systemer. Du kan indsamle data fra jeres ERP-system, sammenholde dem med jeres CRM-system, og krydre det hele med information fra jeres projektliste i SharePoint. Power BI hjælper med at omsætte komplekse datasæt til overskuelige rapporter, som gør det nemt at forstå hvad der sker i forretningen. Det betyder at beslutninger ikke længere baseres på mavefornemmelser - samtidig bygger vi altid rapporterne således at data altid opdateres automatisk. Vi hjælper med at fjerne de manuelle processer, så jeres virksomhed ikke længere er afhængige af eksempelvis manuelle dataudtræk til Excel. ## Ét samlet sted for alle rapporter. Power BI er ikke kun et værktøj til visualisering af data - den største værdi opstår når alle organisationens rapporter er samlet ét sted, og med det samme visuelle udtryk. Når data og rapporter samles på powerbi.com så slipper virksomheden for at filer, lokale versioner, og forskellige data ligger spredt udover organisationen. Man får en "single source of truth". Det betyder at der kun findes én version af sandheden, og man minimerer risikoen for fejl og misforståelser. Når man samler sine data og rapporter på powerbi.com sikrer man sig også at de rigtige personer ser de rigtige data, og følsomme data beskyttes korrekt. Det giver dig en central administration af dine data. Hos Pinksky hjælper vi dig ikke kun med at opbygge dine datasæts og rapporter korrekt - vi hjælper dig også med opsætning og governance. Vi hjælper med at skabe struktur, sikkerhed og ensartethed, og gør det lettere at arbejde datadrevet på tværs af hele organisationen. ## Hvad laver en Power BI konsulent? En Power BI konsulent hjælper med at finde og omsætte rå data til rapporter, som forretningen faktisk bruger. Hos Pinksky dækker vores Power BI konsulenter typisk opgaver som: - **Datamodellering** - fundamentet under enhver god rapport: en datamodel, der er hurtig, korrekt og til at bygge videre på, og samler data fra **ALLE** jeres systemer. - **Rapportdesign** - dashboards og rapporter, der viser det vigtige først, og er til at afkode uden manual, men også muligt at dykke ned i detaljen, hvis man ønsker det. - **Datakilder og opdatering** - opkobling til jeres systemer og opsætning af automatisk opdatering - både fra Microsoft systemer og fra jeres egne systemer og databaser. - **Governance** - arbejdsområder, adgange og udgivelsesprocesser, så rapporterne er til at stole på, og ingen ved en fejl kan ændre eller slette dem. - **Undervisning** - så jeres egne medarbejdere selv kan skabe sig det overblik de ønsker, eller tilpasse rapporterne senere. Uanset om I skal i gang fra bunden, eller har en Power BI opsætning, der trænger til en kærlig hånd, kan vi hjælpe. ## Hvad er Power BI? Power BI er Microsofts værktøj til dataanalyse og visualisering. Det samler data fra virksomhedens systemer i interaktive rapporter og dashboards, så man kan følge sin forretning i tal - i stedet for at basere beslutninger på mavefornemmelser og manuelle Excel-udtræk. --- ## Kan Power BI hente data fra vores systemer? Med stor sandsynlighed, ja. Power BI har connectors til hundredvis af systemer - ERP, CRM, SharePoint, databaser, Excel-filer og API'er - og data kan kombineres på tværs. Vi bygger altid rapporterne, så data opdateres automatisk, uden manuelle udtræk. Har jeres system mod forventning ikke en connector, kan vi ofte hente data via API'er, eller bygge en løsning, der eksporterer data til et format Power BI kan læse, vi har [masser af erfaring med at integrere med andre systemer](/tjenester/integrationer/). --- ## Hvad koster en Power BI konsulent? Det afhænger af opgaven - fra en enkelt rapport til en komplet rapporteringsplatform med governance. Vi arbejder både med afgrænsede opgaver og løbende samarbejde. [Kontakt os](/kontakt/), så giver vi et uforpligtende bud på jeres opgave. --- ### Power Platform - Power Apps, Power Automate, Dataverse, Copilot Studio URL: https://pinksky.dk/tjenester/power-platform/ Beskrivelse: Power Platform løsninger og implementeringer på Microsoft platformen. Microsofts Power Platform tilbyder en række værktøjer, der gør det muligt at bygge low-code/no-code løsninger, til automatisering, eller optimering af arbejdsgange. Hos Pinksky har vi mange års erfaring med at bygge løsninger på Power Platformen og ved ikke bare, hvordan man bedst udnytter platformen, men også hvilke faldgruber du skal være opmærksom på, så du kommer godt fra start. ## Copilot Studio Med Microsoft Copilot Studio kan man bygge sine egne AI-agenter, uden at skrive kode - det betyder at man kan uddelegere vedligeholdelsen af sine agenter til de superbrugere i organisationen, der har de bedste forudsætning for at forstå og optimere agenterne. Vi hjælper dig gerne i gang, så du kommer godt fra start og sikrer, at der er styr på sikkerhed og governance fra begyndelsen. Læs mere om hvordan vi arbejder med AI-agenter [her](/tjenester/ai/). ## Power Automate Med Power Automate, kan arbejdsgange automatiseres, så man sparer tid og reducerer menneskelige fejl. Det er nemt at komme i gang, og Power Automate tilbyder mere end 1400 "connectors". Men selvom det er nemt at komme i gang, skal man holde sig for øje, at man har den rigtige opbygning af sine Power Automate-flows. Man skal vælge den rigtige strategi og sørge for, at “ejerskabet” ikke ligger hos en medarbejder, som pludselig ikke er der længere. Dine flows skal samles, så man har det fulde overblik, og de skal bygges, så de nemt kan vedligeholdes og overtages af andre. Vi bygger flows professionelt og sørger for, at det bliver overskueligt, og vi hjælper gerne med både overblik og korrekt opbygning. ## Power Apps Microsoft Power Apps bruges til at udvikle typiske interne applikationer, uden behov for traditionel kodning. Det gør det muligt at opbygge apps hurtigere, som man kan køre både på sin PC, mobil og tablet - baseret på data direkte fra Microsoft-platformen. Power Apps er særligt stærk når der er behov for at digitalisere manuelle processer, hurtigere end de traditionelle udviklingsforløb. En af de største styrker er integrationen til Microsoft-platformen, hvor der er skabt direkte adgang til eksempelvis SharePoint eller Dataverse. Power Apps giver en særlig værdi når det bruges til at erstatte indsamling af data i Excel-baserede processer, digitalisering af manuelle arbejdsgange, og giver ofte mening at bruge hvor fleksibilitet er vigtigere end standardsoftware. Hos Pinksky ved vi hvornår Power Apps giver mening. Nogle gange er en Power App det rigtige til at løse opgaven, andre gange skal der andre værktøjer til. Vi kender begrænsninger og mulighederne, og hjælper jer altid i den rigtige retning. ## Fra idé til færdig løsning Styrken ved Power Platformen er hastigheden: Vejen fra idé til noget, man kan klikke rundt i, måles i dage - ikke måneder. Derfor arbejder vi altid i korte forløb. Vi starter med at forstå processen og bygger hurtigt en første udgave, som de kommende brugere kan prøve af. Deres feedback former næste version, og på den måde undgår vi at bruge lang tid på at bygge noget, der rammer ved siden af behovet. Når løsningen er i drift, slutter arbejdet sjældent: De fleste løsninger vokser i takt med, at organisationen opdager nye muligheder - og det er præcis sådan, platformen er tænkt. ## Governance: undgå vildtvoksende apps og flows Fordi det er nemt at bygge på Power Platformen, ender mange organisationer med et virvar af apps og flows, som ingen har overblik over: Ingen ved, hvad der findes, hvad der er kritisk, eller hvad der kan slukkes. Eller værre endnu, ting stopper med at virke når en medarbejder forlader organisationen, fordi løsningen er bundet til den enkelte medarbejders login. Det forebygger vi med governance fra starten: - **Miljøstrategi** - adskilte miljøer til udvikling, test og produktion, så eksperimenter aldrig vælter driften. - **Overblik** - en samlet oversigt over organisationens apps og flows, deres formål og deres ejere. - **Retningslinjer for medarbejdere der selv vil bygge** - så engagerede medarbejdere kan bygge selv, inden for trygge rammer. - **Dokumentation og overdragelse** - løsninger bygges, så de kan overtages, ikke kun forstås af den, der byggede dem. ## Hvad er Microsoft Power Platform? Power Platform er Microsofts samling af "low-code" værktøjer: Power Apps til at bygge applikationer, Power Automate til at automatisere arbejdsgange, Copilot Studio til at bygge AI-agenter, og Dataverse som datagrundlag. Værktøjerne gør det muligt at bygge løsninger langt hurtigere end med traditionel udvikling, også uden at være uddannet programmør. --- ## Hvad kan man bygge med Power Apps? Typisk interne applikationer: digitalisering af papir- og Excel-baserede processer, registreringsapps til produktionen eller lageret, godkendelsesløsninger og meget mere. Apps kan bruges på både PC, mobil og tablet, og henter data direkte fra eksempelvis SharePoint eller Dataverse. --- ## Kræver Power Platform ekstra licenser? Ikke nødvendigvis. En stor del af mulighederne i Power Apps og Power Automate er inkluderet i de fleste Microsoft 365 abonnementer. Nogle scenarier - eksempelvis Dataverse eller adgang til tredjepartssystemer - kræver tillægslicenser. Vi hjælper med at designe løsningen, så I ikke betaler for mere end nødvendigt, men stadig får den funktionalitet, I har brug for. --- ## Hvad koster en Power Platform løsning? Det afhænger af opgavens omfang - fra et enkelt automatiseringsflow til en komplet forretningsapplikation. [Kontakt os](/kontakt/) med jeres idé, så giver vi en ærlig vurdering af, hvad der skal til, og hvad det vil koste. --- ### Procesautomatisering URL: https://pinksky.dk/tjenester/procesautomatisering/ Beskrivelse: Procesautomatisering løsninger og implementeringer på Microsoft platformen. De fleste organisationer har manuelle og tidskrævende processer: onboarding, udgiftsgodkendelser, kundehenvendelser m.m. Ved at identificere og automatisere de manuelle processer hjælper man medarbejderne med at fokusere på aktiviteter, der skaber forretningsværdi. ## Hvad er procesautomatisering? Procesautomatisering er at automatisere gentagne processer, så man enten helt fjerner den manuelle proces, eller hjælper medarbejderne med at udføre deres opgaver hurtigere og mere effektivt. Og vigtigst af alt, ved at automatisere processer kan organisationer reducere fejl, skabe mere konsistens og forbedre produktiviteten. Det frigiver tid til andre værdiskabende opgaver. ## Hvordan kan vi hjælpe? Microsoft-platformen tilbyder en lang række værktøjer til at automatisere processer, eller til at integrere med tredjeparts systemer. Vi hjælper med at identificere, designe og implementere automatiseringer, der skaber værdi for din organisation. Det kan være automatiseringer bygget i Power Automate, der sender simple reminders om en udløbet kontrakt, eller giver dig en advarsel i god tid, inden den udløber. Det kan også være mere komplekse løsninger, hvor vi kombinerer Power Automate med Power Apps, Azure Functions og Logic Apps, for at skabe en skræddersyet løsning, der passer til netop jeres behov, og integrerer med jeres eksisterende systemer. ## Eksempler på automatiseringer - Onboarding af nye medarbejdere. - Godkendelses workflows for udgifter, ferier og rejser. - Automatiserede kundehenvendelser og support tickets. - Integration mellem forskellige systemer (f.eks. CRM, ERP, HR-systemer) - Automatiserede rapporter og dataindsamling. - Notifikationer og påmindelser om vigtige deadlines. ## Sådan arbejder vi med automatisering En automatisering er kun en gevinst, så længe den kører pålideligt - også den dag, processen ændrer sig. Derfor arbejder vi struktureret: 1. **Kortlægning** - vi gennemgår processen sammen med de medarbejdere, der udfører den i dag, og fanger de undtagelser, der aldrig står i procesbeskrivelsen, og identificerer nuancer der måske ikke tidligere har været tænkt ind. 2. **Prioritering** - vi regner på gevinsten, så vi sætter ind der, hvor automatiseringen tjener sig hjem hurtigst, det nytter ikke noget at spare en medarbejder 2 minutter om året, hvis vi kan spare 300 medarbejdere 10 minutter dagligt. 3. **Byg og test** - løsningen bygges med håndtering af fejl og undtagelser, ikke kun "den glade sti", vi tester grundigt og sikrer, at alle scenarier er dækket, i sammenspil med jer, så løsningen er intuitiv når den rulles ud til resten af organisationen. 4. **Drift og ejerskab** - løsningen dokumenteres og får en ejer, så I selv kan vedligeholde den, og drive den interne støtte, uden at skulle ringe til os hver gang der er en ændring i processen. ## Kan man bruge AI til procesautomatisering? **Ja!** - Med værktøjer som Copilot Studio kan man bygge AI-agenter, der kan håndtere komplekse processer, som tidligere krævede menneskelig indgriben. Det kan være alt fra at besvare kundehenvendelser, til at analysere data og træffe beslutninger baseret på foruddefinerede regler. --- ## Hvilke processer egner sig til automatisering? De bedste kandidater er processer, der gentages ofte, følger faste regler, og involverer flere personer eller systemer - eksempelvis godkendelser, onboarding, påmindelser om deadlines og dataoverførsler mellem systemer. Jo mere manuel copy-paste en proces indeholder, jo større er gevinsten typisk. --- ## Hvilke værktøjer bruger I til procesautomatisering? Primært Power Automate, som er inkluderet i de fleste Microsoft 365 abonnementer. Ved mere komplekse behov kombinerer vi med Power Apps, Azure Functions og Logic Apps. Vi vælger altid værktøjet ud fra opgaven - ikke omvendt. --- ## Hvordan undgår I at automatiseringen stopper med at virke? Vi bestræber os altid på at bygge robuste løsninger, der håndterer fejl og undtagelser, men det hænder at tredjepartssystemer ændrer sig, og at automatiseringen derfor stopper med at virke. Derfor implementerer vi altid en form for overvågning, så I får besked, hvis noget går galt, og kan reagere hurtigt - ingen automatiseringer, der har stået stille i flere uger, uden at nogen har opdaget det. --- ## Hvad koster det at automatisere en proces? Et simpelt påmindelsesflow er en lille opgave, mens en automatisering på tværs af flere systemer er et projekt. Gevinsten skal altid stå mål med investeringen - derfor starter vi med at identificere de processer, hvor automatisering betaler sig. [Kontakt os](/kontakt/) for en snak om jeres processer. --- ### Second Opinion URL: https://pinksky.dk/tjenester/second-opinion/ Beskrivelse: Uvildig second opinion på tilbud, igangværende projekter eller leverede løsninger på Microsoft-platformen - uden bindinger til én leverandør. Er dit Microsoft 365, Dynamics 365 eller Azure projekt gået i hårdknude? Eller er du i tvivl om den løsning du har fået tilbudt er den rette for dig og din organisation? Hos Pinksky har vi mange års erfaring med at hjælpe organisationer med at få succes med deres Microsoft projekter. Vi ved hvor vigtigt det er at få den rette løsning, der passer til netop jeres behov. Microsoft-platformen tilbyder mange forskellige former for løsninger, og det er ikke nødvendigvis altid man har fået tilbudt det rigtige værktøj til at løse opgaven. ## Gennemgang af tilbud Har du fået et tilbud på en løsning baseret på Microsoft 365, Dynamics 365 Sales eller Azure? Har du svært ved at gennemskue tilbuddet? Eller er der noget som gør dig i tvivl? Lad os sammen tage en snak hvor vi gennemgår tilbuddet eller oplægget. Vi giver vores uvildige vurdering af om det er den rette løsning, eller om der er andre muligheder der kunne være bedre - **helt uden salgsgas.** ## Analyse af igangværende projekt Er dit digitaliseringsprojekt gået i hårdknude, med mangelfuld kommunikation og uindfriede ønsker, eller er du bare i tvivl om, hvorvidt I er på rette vej? Hos Pinksky har vores konsulenter mange års erfaring med at hjælpe organisationer med at få succes med Microsoft-platformen. Vi har set projekter der er gået i hårdknude, eller som ikke levede op til de forventninger organisationen havde. Derfor er vi også altid klar til at hjælpe med en uvildig second opinion, hvor vi gennemgår det forløb, I har været igennem. Vi hjælper jer tilbage på rette spor, også selvom det er med en anden leverandør - vi er her for at hjælpe jer med at få succes. ## Gennemgang af eksisterende løsning Har I fået leveret en løsning i Microsoft-platformen, som I ikke synes lever op til hvad I blev stillet i udsigt? Er systemet tungt at arbejde i? Opstår der fejl I ikke kan identificere hvor de kommer fra? Eller er I kommet i tvivl om I er endt med den rigtige løsning til jeres behov? Så lad os tage en snak. Vi tilbyder alt fra en hurtig gennemgang af jeres løsning til en dybdegående analyse, eksempelvis "code reviews" og "architecture assessments". Det giver jer noget konkret at arbejde videre med, uanset om det er med os eller en anden leverandør. ## Sådan foregår en second opinion Et second opinion forløb er bevidst enkelt, så I hurtigt får svar: 1. **En uforpligtende snak** - I fortæller, hvad der bekymrer jer: tilbuddet, projektet, kommunikationen eller løsningen. Vi aftaler sammen, hvad vurderingen skal fokusere på. 2. **I deler materialet** - tilbud, kravspecifikationer, projektplaner, adgang til løsningen eller andet relevant - naturligvis fuldt fortroligt. 3. **Vi gennemgår** - med friske øjne, uden ejerskab eller følelser i klemme - og så objektivt som muligt. 4. **I får vurderingen** - en klar og ærlig tilbagemelding med konkrete anbefalinger, som vi gennemgår sammen, så I kan handle på den. ## Hvad er en second opinion på et IT-projekt? En uafhængig vurdering af et tilbud, et igangværende projekt eller en leveret løsning, foretaget af en anden end den leverandør, der står bag. Formålet er at få et ærligt svar på, om I er på rette vej - inden der bliver brugt flere penge. Eller om det måske er bedre at stoppe op, og overveje andre muligheder. Det kan være en anden leverandør, en anden løsning, eller at vente med at investere, indtil I har mere klarhed over behovet. --- ## Hvornår giver en second opinion mening? Typisk i tre situationer: Når I har fået et tilbud, I har svært ved at gennemskue. Når et igangværende projekt er gået i hårdknude, eller I er i tvivl om retningen. Eller når en leveret løsning ikke lever op til det, I blev stillet i udsigt. --- ## Er vurderingen uvildig? Ja. Vi giver vores ærlige vurdering, uden salgsgas - også hvis konklusionen er, at jeres nuværende leverandør har leveret den rigtige løsning, og det er jer der ikke har haft klarhed over jeres ønsker, eller at vejen videre er med en anden leverandør end os. Målet er, at I får succes med jeres projekt. --- ## Hvad får vi konkret ud af det? Det afhænger af opgaven - fra en mundtlig vurdering efter en gennemgang af et tilbud, til en dybdegående analyse med code review eller architecture assessment. I alle tilfælde får I noget konkret at arbejde videre med, uanset om det er med os eller en anden leverandør. --- ### SharePoint Online URL: https://pinksky.dk/tjenester/sharepoint/ Beskrivelse: Maksimer dit udbytte af SharePoint med skræddersyede løsninger og implementeringer på Microsoft platformen. SharePoint er måske verdens førende digitale platform inden for samarbejde og dokumenthåndtering. Med mere end 25 års historie og over 1.000.000.000 månedlige brugere, er SharePoint en solid og velafprøvet platform. Hos Pinksky har vi mere end 15 års samlet erfaring, med at implementere og tilpasse SharePoint. Vi hjælper virksomheder med at få mest værdi ud af platformen. ## SharePoint er midt i Microsoft 365 Selvom SharePoint godt kan blive overset i Microsoft 365 pakken, danner det faktisk grundlaget for mange af de mest centrale funktioner i Microsoft 365. Det er SharePoint der håndterer dokumentlagring i OneDrive og Teams. Det er SharePoint der driver intranetløsninger, og det er SharePoint der muliggør samarbejde og videndeling på tværs af organisationen. SharePoint er endda kernen i Microsoft 365 Copilots vidensgrundlag. Det er netop derfor, det er så vigtigt, at have en solid SharePoint implementering. --- ## SharePoint der skaber værdi Nogle gange ser vi at SharePoint ikke lever op til platformens fulde potentiale, og i stedet ender med at blive en kilde til frustration for brugerne - frem for et værktøj, der skaber værdi. Det sker oftest, når SharePoint bliver en erstatning for virksomhedens eksisterende netværksdrev. Implementeringen sker uden en klar strategi, og uden at tage højde for virksomhedens processer og arbejdsgange. SharePoint har uendelige opsætningsmuligheder, og med den rigtige strategi og rette kendskab til platformen, kan SharePoint hjælpe virksomheden med at få overblik og succes. Hos Pinksky ved vi, at en succesfuld SharePoint-implementering, kræver mere end kendskab til teknologi - det kræver en forståelse for organisationens behov, brugernes arbejdsgange, og en solid plan for hvordan informationsarkitekturen skal opbygges. Det har vi brugt mere end et årti på at mestre, og vi kan hjælpe jer med at få mest muligt ud af jeres SharePoint-platform. ## Skræddersyede SharePoint løsninger SharePoint er en meget fleksibel platform. Det betyder også at platformen kan tilpasses og udvides på mange forskellige måder. Man kan selv gå i gang med at tilpasse platformen, og lære af sine fejl undervejs, eller man kan lægge en strategi, og udvikle platformen med nogle som har prøvet det før. Vi har kompetencer inden for hele spektret af tilpasningsmulighederne i SharePoint - fra brand ændringer, eller "low-code" tilpasninger af formularer, til komplekse workflows og specialtilpassede moduler, eller integrationer med andre systemer. Uanset om der er behov for en intranet løsning, eller en kompleks forretningsapplikation bygget på SharePoint - eller bare hjælp til at vælge hvad den rigtige vej er for organisationen, så har vi erfaringen og ekspertisen til at hjælpe med at realisere virksomhedens visioner. ## Hvad laver en SharePoint konsulent? En SharePoint konsulent hjælper med at omsætte platformens mange muligheder til løsninger, der passer til organisationens behov. Hos Pinksky dækker vores SharePoint konsulenter typisk opgaver som: - **Informationsarkitektur** - strukturen af arbejdsområder, biblioteker og metadata, så information altid er til at finde. - **Intranet** - opsætning af [moderne SharePoint intranets](/loesninger/intranet/) med nyheder, processer og værktøjer samlet ét sted. - **Dokumenthåndtering** - versionsstyring, adgangsstyring og automatiserede dokumentprocesser, eksempelvis [kontraktstyring](/loesninger/kontraktstyring/). - **Migrering** - flytning af filer fra netværksdrev eller ældre SharePoint versioner til SharePoint Online. - **Automatisering** - workflows og godkendelsesprocesser bygget med [Power Automate](/tjenester/power-platform/). - **Tilpasning og udvikling** - fra formulartilpasninger og branding, til specialudviklede webparts og integrationer med andre systemer. - **Provisioning og livscyklus** - automatiseret oprettelse og nedlukning af arbejdsområder, med styring af rettigheder og livscyklus. - **Governance og rådgivning** - retningslinjer, rettigheder og strategi, så platformen forbliver sund, også når den vokser. - **Undervisning** - så jeres egne medarbejdere selv kan vedligeholde og videreudvikle platformen. Uanset om I har brug for en SharePoint specialist til et enkelt projekt, en udvikler til en konkret tilpasning, eller en fast samarbejdspartner omkring hele jeres Microsoft 365 platform, kan vi hjælpe. ## SharePoint konsulenter Hos Pinksky hjælper vi virksomheder i hele Danmark - både på jeres adresse og remote. Vores team tæller blandt andet en [Microsoft MVP](https://mvp.microsoft.com/), en anerkendelse Microsoft kun giver til få specialister med særligt dyb viden om platformen og et aktivt bidrag til communityet. ## Hvad er SharePoint Online? SharePoint Online er cloud-udgaven af SharePoint, og en del af Microsoft 365. Det er platformen bag dokumenthåndtering, intranets og samarbejde i Microsoft 365 - og det er den udgave, stort set alle nye løsninger bygges på i dag, i modsætning til de ældre SharePoint Server versioner, man selv skulle drifte "on-premises". --- ## Hjælper I med implementering af SharePoint? Ja - implementering af SharePoint er en af vores kerneydelser. Vi hjælper både virksomheder, der starter helt fra bunden, og virksomheder, der allerede bruger SharePoint, men ikke får nok værdi ud af platformen. En god implementering starter med strategien og informationsarkitekturen - ikke med teknikken. --- ## Hvad er forskellen på en SharePoint konsulent, specialist og udvikler? Betegnelserne bruges ofte i flæng. En konsulent rådgiver om strategi, struktur og processer, en specialist har dyb viden om platformens konfigurationsmuligheder, og en udvikler bygger tilpasninger og udvidelser i kode. Hos Pinksky dækker vi alle tre roller, med mange års erfaring i feltet, så I ikke behøver skelne. --- ## Hvad koster en SharePoint konsulent? Det afhænger af opgavens karakter og omfang. Vi arbejder både med afgrænsede projekter og løbende samarbejde på timebasis. [Kontakt os](/kontakt/) og fortæl om jeres behov, så giver vi et konkret og uforpligtende bud. --- ### Uvildig rådgivning URL: https://pinksky.dk/tjenester/raadgivning/ Beskrivelse: Uvildig rådgivning om digitalisering og Microsoft 365 - uden bindinger til bestemte produkter eller leverandører. Går du med overvejelser om at optimere jeres digitale arbejdsplads eller implementere nye løsninger? Er du i tvivl om hvorvidt den leverandør, du har valgt, er den rigtige? Så står Pinksky klar til at hjælpe. Vores erfarne konsulenter tilbyder uvildig rådgivning, der sikrer, at I træffer de bedste beslutninger for jeres organisation. ## Digitaliserings og IT-rådgivning Selvom vi selv tilbyder implementering af løsninger inden for Microsoft 365, SharePoint, Power Platformen og andre teknologier, er vores primære formål at sikre, at I vælger de rigtige værktøjer og strategier, der passer til jeres unikke behov. Derfor tilbyder vi også rådgivning som en selvstændig ydelse. Uanset om I står med et konkret tilbud fra en anden leverandør eller blot ønsker at udforske jeres muligheder, kan vores konsulenter give jer en objektiv vurdering og anbefalinger baseret på jeres specifikke situation. ## Digital strategi og planlægning Digitalisering kan være en kompleks proces, fyldt med buzzwords, teknologier og afgørende valg. Vi hjælper jer gennem rejsen, ved at tilbyde klar og letforståelig rådgivning. Det gør vi, uanset om I vælger selv at stå for implementeringen, ønsker os som jeres betroede partner, eller arbejder med en tredje part. Vi hjælper jer med at udvikle en digital strategi, der understøtter jeres forretningsmål og sikrer, at I får mest muligt ud af jeres investering. Fordi, vi er **in IT together.** ## Det kan I bruge os til Uvildig rådgivning kan lyde abstrakt - her er eksempler på opgaver, vi kan hjælpe med: - **Gennemgang af tilbud** - inden I skriver under på et større IT-projekt, gennemgår vi tilbuddet og stiller de spørgsmål, I ikke selv har forudsætningerne for at stille. - **Valg mellem leverandører** - vi hjælper med at sammenligne æbler og pærer, når tilbuddene er skruet forskelligt sammen, eller fyldt med tekniske begreber almindelige mennesker ikke forstår. - **Arkitektur-review** - et eftersyn af jeres Microsoft 365 opsætning, er der styr på sikkerhed, governance, og er jeres opsætning optimeret til jeres behov? - **Sparringspartner for IT-ansvarlige** - en erfaren modspiller at vende beslutninger med, uanset om der skal købes et projekt, eller bare lige drøftes hvordan den bedste model for noget ser ud. ## Hvad vil det sige, at rådgivningen er uvildig? At vores anbefaling ikke er bundet til et bestemt produkt eller en bestemt leverandør. Vi sælger ikke licenser, og vi tjener ikke på, at I vælger en bestemt løsning. Anbefalingen baseres alene på jeres behov - også hvis den rigtige løsning ikke er en, vi selv leverer. --- ## Hvornår giver uvildig IT-rådgivning mening? Typisk når I står over for et større valg: et stort digitaliseringsprojekt, et tilbud fra en leverandør I har svært ved at gennemskue, eller hvis I har mistet tilliden til jeres nuværende leverandør. En uvildig vurdering tidligt i forløbet er billig, sammenlignet med at vælge forkert. --- ## Hvordan foregår et rådgivningsforløb? Vi starter med at forstå jeres situation og behov. Derefter afhænger forløbet af opgaven - fra en enkelt workshop med en konkret anbefaling, til et længere strategiforløb. I får altid klar og letforståelig rådgivning uden buzzwords. --- ## Kan I også hjælpe med implementeringen bagefter? Ja, hvis I ønsker det. Vi tilbyder selv implementering inden for Microsoft 365, SharePoint og Power Platformen - men rådgivningen forpligter ikke. I kan lige så godt bruge vores anbefalinger sammen med en anden leverandør eller implementere selv. For os er det vigtigste at I får den rigtige løsning, uanset hvem der implementerer den. --- ## Løsninger ### Intranet på SharePoint Online URL: https://pinksky.dk/loesninger/intranet/ Beskrivelse: Moderne SharePoint Online intranet der styrker kommunikation og medarbejderoplevelse. Med et moderne intranet bygget på SharePoint, styrker du ikke blot din organisations kommunikationskanaler, men du giver medarbejderne en hurtigere og mere effektiv adgang til nyheder og jeres arbejdsværktøjer. Det gør hverdagen for dine medarbejdere nemmere, og letter jeres onboarding. ## Et intranet er ikke bare et intranet Vi møder tit virksomheder der er blevet solgt et "intranet in a box", og nu sidder tilbage med en tom følelse. Hvad er det egentligt der har ændret sig? Og hvad får vi for vores licensomkostninger? Hos Pinksky, tror vi ikke på at der findes to organisationer der er helt ens. Vi tror heller ikke på at man bare kan proppe alle organisationer ned i en kasse. Derfor har vi **ikke** et intranet produkt, men vi hjælper derimod med at konfigurere jeres SharePoint intranet, til jeres ønsker og behov. Vi har mange års erfaring med at bygge intranets. Lige fra små startups, til organisationer med flere tusinde medarbejdere. ### Hvad er et moderne intranet? Et moderne intranet er meget mere end blot et sted hvor der bliver skrevet en nyhed engang imellem. Det moderne intranet er et centralt samlingssted, både for informationer, processer, og de værktøjer der benyttes i organisationen. Med SharePoint får du mange standard funktioner ud af boksen, som de fleste organisationer har behov for. Vi hjælper jer med at konfigurere platformen, men klæder jer også på til selv at kunne vedligeholde den i fremtiden. Vi bestræber os på at konfigurere jeres intranet indenfor standard platformen, men er der nogle helt specifikke behov eller ønsker som ikke kan løses med standard konfigurering, så hjælper vi jer med at udvide platformen med special tilpassede løsninger eller integrationer. ### Hvad er de typiske funktioner på et intranet? I dag ser vi intranets der indeholder mange forskellige aspekter, men et par af de mest almindelige funktioner er: - **Nyheder og kommunikation**: Ledelsen kan kommunikere vigtige informationer til alle medarbejdere, og medarbejderne kan dele nyheder og opdateringer med hinanden. - **Processer og procedurer**: Medarbejderne kan nemt finde organisationens processer og procedurer, så de altid ved hvordan tingene skal gøres. - **Værktøjer og ressourcer**: Medarbejderne kan hurtigt finde de værktøjer og ressourcer de har brug for i deres daglige arbejde, eksempelvis dokumenter, skabeloner og vejledninger. - **Søgning**: En effektiv søgefunktion, der gør det nemt for medarbejderne at finde det de leder efter. Uanset om det er dokumenter, nyheder eller en kollega. - **Kantineordning**: En digital løsning til at bestille frokost, eller se hvad der er på menuen i dag. - **Medarbejdergoder**: En oversigt over de forskellige medarbejdergoder der tilbydes i organisationen, eksempelvis sundhedsforsikring og rabataftaler. - **Onboarding**: En struktureret onboarding proces, der hjælper nye medarbejdere med at komme godt i gang. - **Sociale funktioner**: Funktioner der fremmer social interaktion blandt medarbejderne, ex. fødselsdage, sociale grupper eller digitale opslagstavler. - **Integrationer**: Integrationer med andre systemer og værktøjer, ex. HR-systemer, projektstyringsværktøjer og CRM-systemer. ## Hvad koster et intranet? Det korte svar: Det afhænger af ambitionsniveauet - men sandsynligvis mindre end du tror. SharePoint er inkluderet i stort set alle Microsoft 365 abonnementer, så selve platformen har I formentlig allerede betalt for, hvis I har adgang til Office-pakken. I modsætning til "intranet in a box" produkterne, er der med et SharePoint intranet ingen løbende licensomkostning pr. medarbejder oveni. Investeringen ligger derfor i opsætningen: informationsarkitekturen, designet, konfigurationen og uddannelsen af jeres redaktører. Et mindre intranet til en lille organisation kan være i luften på få dage, mens større organisationer med mange afdelinger, lande og integrationer naturligt kræver mere. [Kontakt os](/kontakt/) og få et konkret bud på netop jeres intranet. ## Sådan bygger vi jeres intranet 1. **Behovsafdækning** - vi starter med en workshop, hvor vi afdækker jeres behov, ønsker og eksisterende arbejdsgange. 2. **Informationsarkitektur** - vi designer strukturen: arbejdsområder, navigation, målgrupper og rettigheder. 3. **Design og konfiguration** - vi opsætter intranettet i jeres eget Microsoft 365 miljø, med jeres branding og indhold. 4. **Udrulning og uddannelse** - vi hjælper med lanceringen, og uddanner jeres redaktører, så intranettet holdes levende. Du kan læse mere om vores kompetencer på platformen under [SharePoint](/tjenester/sharepoint/). ### Intranettet med jer i Teams Med SharePoint appen i Teams (tidligere Viva Connections) kan jeres SharePoint intranet vises direkte i Microsoft Teams. Det betyder, at nyheder, processer og værktøjer er let tilgængelige, uanset om du er på mobilen, i browseren eller i Teams. Det giver en mere sammenhængende oplevelse for medarbejderne, og gør det nemmere at holde sig opdateret. ## Kan intranettet vises på flere sprog? Ja. SharePoint har indbygget understøttelse af flersprogede intranets, hvor sider og nyheder kan oversættes, og den enkelte medarbejder automatisk ser indholdet på sit foretrukne sprog. Det er især relevant for organisationer med afdelinger i flere lande. --- ## Hvor lang tid tager det at bygge et intranet? Typisk taler vi uger fra den første workshop til lanceringen - ikke måneder. Erfaringen er, at det sjældent er teknikken, der tager tid, men beslutningerne: Hvordan skal strukturen være, hvem skriver indholdet, og hvem ejer hvad fremover? --- ## Hvad er forskellen på et intranet og Microsoft Teams? Teams er til samarbejde og dialog i mindre grupper - intranettet er organisationens fælles sted for nyheder, processer og værktøjer. De to supplerer hinanden, og bruges bedst sammen. --- ## Kan vi selv vedligeholde intranettet? Ja! - Vores mål er at træne jeres redaktører, så I selv styrer nyheder, sider og indhold, og med redaktørroller og rettigheder kan ansvaret fordeles på tværs af afdelinger - uden at I skal tilbage til os for hver ændring. --- ### Kontraktstyring URL: https://pinksky.dk/loesninger/kontraktstyring/ Beskrivelse: Kontraktstyring løsninger og implementeringer på Microsoft platformen. I enhver organisation findes der kontrakter. Hvad end det er aftalen med kaffeleverandøren, serviceaftalen med IT leverandøren, eller de juridiske aftaler mellem kunder og partnere. Kontrakter skal være nemt tilgængelige, og de rigtige medarbejdere skal have de korrekte adgange. Det er vigtigt man har en godt overblik over sine kontrakter. Hvornår udløber den? Hvornår skal den genforhandles? Hvem har ansvaret for kontrakten? ## Kontraktstyring i Microsoft 365 Microsoft 365 tilbyder en bred vifte af værktøjer, der hjælper med at organisere og automatisere processerne med kontrakter. Ved at udnytte platformens funktioner, kan organisationer forbedre kontraktstyringen betydeligt. Mulighederne kan benyttes som et simpelt setup, der blot sørger for at der er samling på kontrakterne - men også som en automatiseret kontraktstyringsløsning, der understøtter hele kontraktens livscyklus fra oprettelse til genforhandling og arkivering. ## Fordele ved digital kontraktstyring - **Centraliseret opbevaring**: Alle kontrakter gemmes sikkert ét sted, hvilket gør det nemt at finde og administrere dem. - **Versionsstyring**: Se ændringer og historik for hver enkelt kontrakt. Så kan man altid se tidligere versioner. - **Rettighedsstyring**: Kontroller hvem der har adgang til hvilke kontrakter, og beskyt følsomme oplysninger. - **Automatiserede processer**: Automatiser påmindelser om fornyelser, godkendelser og andre vigtige milepæle. - **Strømlinede godkendelser**: Med en digitaliseret godkendelsesproces kan kontrakter hurtigt gennemgås og godkendes af de relevante parter. - **Dataanalyse og rapportering**: Få indsigt i kontraktdata for bedre beslutningstagning og risikostyring. - **AI-drevet dataudtræk**: Brug kunstig intelligens til at udtrække nøgleinformationer, for at minimere manuel indsats og fejl. Benytter du dig af Microsoft 365 platformen til at håndtere dine kontrakter, får du også muligheden for at **Microsoft 365 Copilot** kan hjælpe med at opsummere kontrakter, identificere centrale punkter, fremhæve væsentlige vilkår og afdække relevante detaljer. ## Hvad er et kontraktstyringssystem? Et kontraktstyringssystem er et værktøj, der samler alle virksomhedens kontrakter i ét overblik, og holder styr på stamdata som aftalepart, udløbsdato, opsigelsesfrist og hvem den ansvarlige er. Et godt system til kontraktstyring gør det nemt at finde den rigtige kontrakt frem, når man skal bruge den - uanset om det er til en forhandling, en revision eller en tvist. Mange leder efter et program til kontraktstyring, eller en decideret kontraktstyringssoftware, men i praksis er behovet ofte simplere end som så: Et samlet overblik, styr på adgangene, og automatiske påmindelser. Præcis det kan Microsoft 365 levere. ## Kontraktstyring uden endnu et nyt system Markedet er fyldt med standardsystemer til kontraktstyring, og mange af dem er glimrende produkter. Men de kommer også med nye licenser, endnu et login medarbejderne skal huske, og endnu et sted jeres data skal ligge. Hos Pinksky bygger vi i stedet kontraktstyringen på den Microsoft 365 platform, I allerede har: - **Ingen nye licenser** - løsningen bygger på SharePoint Online og Power Automate, som er inkluderet i de fleste Microsoft 365 abonnementer. - **Ingen nye logins** - medarbejderne arbejder i de værktøjer, de kender i forvejen. - **Jeres data bliver hos jer** - kontrakterne ligger i jeres eget Microsoft 365 miljø, omfattet af jeres eksisterende sikkerhed og compliance. - **Skræddersyet til jeres behov** - I betaler ikke for en lang række funktioner, I aldrig får brugt. ## Automatisér kontraktens livscyklus En kontrakt er ikke bare et dokument - der er en livscyklus. Den bliver udarbejdet, forhandlet, godkendt og underskrevet, og på et tidspunkt skal den enten genforhandles, fornys eller opsiges. Med Power Automate kan vi automatisere processerne omkring hele livscyklussen: Nye kontrakter sendes automatisk til godkendelse hos de rigtige personer. Den kontraktansvarlige får en påmindelse i god tid inden opsigelsesfristen. Og udløbne kontrakter arkiveres automatisk, så overblikket altid er ajour. Du kan læse mere om, hvorfor struktureret kontraktstyring er en god forretning, i vores blogindlæg [Hvorfor er kontraktstyring vigtigt?](/blog/2026-07-13-hvorfor-er-kontraktstyring-vigtigt/) ## Hvad er kontraktstyring? Kontraktstyring er processen med at holde styr på virksomhedens kontrakter gennem hele livscyklussen - fra udarbejdelse og godkendelse, over den løbende opfølgning på frister og forpligtelser, til genforhandling eller opsigelse. --- ## Kan man benytte AI til kontraktstyring? **Ja!** - Med Microsoft 365 Copilot kan du trygt benytte AI til at hjælpe med kontraktstyringen. Copilot kan hjælpe med at opsummere kontrakter, identificere centrale punkter, fremhæve væsentlige vilkår og afdække relevante detaljer. Det kan spare tid og minimere risikoen for fejl. --- ## Hvad er forskellen på kontraktstyring og kontrakthåndtering? Begreberne bruges ofte i flæng. Kontrakthåndtering handler typisk om det praktiske: at opbevare, organisere og kunne finde kontrakterne. Kontraktstyring dækker derudover også den aktive opfølgning - frister, ansvar, godkendelser og fornyelser. --- ## Hvad koster et kontraktstyringssystem? Bygger man på Microsoft 365, er der typisk ingen løbende licensomkostninger oveni dem, I allerede betaler. Investeringen ligger i opsætningen og tilpasningen til jeres behov, og afhænger derfor af løsningens omfang. [Kontakt os](/kontakt/) for en uforpligtende snak om jeres behov. --- ## Kan vi selv vedligeholde løsningen? Ja. Løsningen bygger på standardfunktioner i SharePoint Online, så jeres egne medarbejdere kan oprette kontrakter, justere påmindelser og styre adgange uden hjælp udefra. Vi sørger for, at I bliver klædt godt på undervejs. --- ## Hvordan kommer vi i gang? Typisk starter vi med en kort workshop, hvor vi afdækker jeres behov: Hvilke typer kontrakter har I? Hvem skal have adgang til hvad? Og hvilke processer skal automatiseres? Derefter bygger vi løsningen, og de fleste er i luften i løbet af få uger. --- ### Kvalitetsstyring URL: https://pinksky.dk/loesninger/kvalitetsstyring/ Beskrivelse: Kvalitetsstyring løsninger og implementeringer på Microsoft platformen. Kvalitetsstyring er en vigtig funktion i mange virksomheder. Det er det der sikrer, at virksomheden bliver ved med at udvikle sig, og opretholder sin konkurrenceevne. Det er vigtigt ikke kun at understøtte arbejdsprocesserne, men også at sikre, at de er let tilgængelige for alle medarbejdere. Også uanset hvor de befinder sig, og hvilken enhed de anvender. Med en kvalitetsstyringsløsning i Microsoft platformen, sikrer du, at dine medarbejdere altid har adgang til de informationer de har brug for. Det gør det nemmere at kunne udføre sit arbejde effektivt, og i overensstemmelse med virksomhedens kvalitetsstandarder. ## Hvad er kvalitetsstyring? Kvalitetsstyring er de processer og procedurer, en virksomhed bruger til at sikre, at produkter og ydelser konsekvent lever op til kundernes forventninger og gældende krav. I praksis handler det om at beskrive, hvordan tingene skal gøres, at sikre at beskrivelserne bliver fulgt - og at lære af det, når noget går galt. Kvalitetsstyring kan dog have mange forskellige betydninger afhængigt af, hvem man spørger. Derfor tilbyder vi ikke et standardprodukt, men hjælper i stedet med at udnytte Microsoft platformen til at understøtte netop jeres processer - uanset om der er behov for understøttelse af procesbeskrivelser, godkendelse af dokumentændringer eller automatiske påmindelser. ## Kvalitetsstyring behøver ikke være kompliceret Mange virksomheder ender med at bliver overvældet af store standardsystemer der er designet til at hjælpe med eksempelvis QHSE, ISO, ECM og generel processtyring. Ofte fordi systemerne er defineret på forhånd. Man risikerer også at man betaler for en række funktioner, som man i praksis sjældent benytter. Med en kvalitetsstyringsløsning bygget på Microsoft platformen, får du en løsning der er tilpasset virksomhedens konkrete behov. Det er enkelt at benytte, for alle medarbejdere, uanset hvor de befinder sig, og hvilken enhed de arbejder med. ## Det kan en kvalitetsstyringsløsning på Microsoft 365 - **Dokumentstyring med versionshistorik** - procedurer og instruktioner ligger ét sted, og det er altid den gældende version, medarbejderne ser. - **Godkendelsesflows** - ændringer i procedurer sendes automatisk til godkendelse, inden de publiceres. - **Afvigelseshåndtering** - registrér afvigelser i en simpel formular, og følg op med automatiske påmindelser til de ansvarlige. - **Automatiske påmindelser** - når dokumenter skal revideres, eller når opfølgninger ikke bliver lukket. - **Sporbarhed** - fuld historik over hvem der har ændret og godkendt hvad, og hvornår. Uundværligt, når der er audit. - **Adgang for alle** - også for medarbejdere i produktionen, på lageret eller på farten, via telefon eller tablet - med en frontline worker licens får alle billig adgang til de mest opdaterede procedurer. ## Kvalitetsstyring eller kvalitetssikring? De to begreber bliver ofte blandet sammen. Kvalitetssikring er de konkrete aktiviteter, der sikrer kvaliteten af det enkelte produkt eller den enkelte leverance - eksempelvis kontroller og test. Kvalitetsstyring er det overordnede system: processerne, procedurerne og ansvaret, der sikrer, at kvalitetsarbejdet fungerer i hele organisationen. Med andre ord: Kvalitetssikring er en del af kvalitetsstyringen. ## Hvad er et kvalitetsstyringssystem? Et kvalitetsstyringssystem er det sted, hvor virksomhedens procedurer, instruktioner og kvalitetsprocesser samles og styres - med versionering, godkendelser og opfølgning. Det er også dét system, en kontrollør beder om at se dokumentationen i, når virksomheden skal certificeres eller re-certificeres. --- ## Kan man benytte AI til kvalitetsstyring? **Ja!** - Med Microsoft 365 Copilot kan du trygt benytte AI til at hjælpe med kvalitetsstyringen. Copilot kan hjælpe med at opsummere procedurer, identificere centrale punkter, fremhæve væsentlige vilkår og afdække relevante detaljer. Det kan spare tid og minimere risikoen for fejl. --- ## Hvordan kommer vi i gang? Vi tager udgangspunkt i det, I allerede har - kvalitetshåndbogen, procedurerne og de eksisterende arbejdsgange. På en kort workshop afdækker vi, hvilke processer der trykker mest, og bygger derefter løsningen trinvist, så I ser værdi fra starten, og så bygger vi på derfra, i stedet for et stort projekt, der først giver værdi, når det er færdigt. --- ## Kan vi selv vedligeholde løsningen? Ja, med en kvalitetsstyringsløsning bygget på Microsoft platformen, kan I selv styre indholdet, og med redaktørroller og rettigheder kan ansvaret fordeles på tværs af afdelinger - uden at I skal tilbage til os for hver ændring. --- ## Hvad koster en kvalitetsstyringsløsning? Bygger man på Microsoft 365, er der typisk ingen nye licensomkostninger - investeringen ligger i opsætningen, og afhænger af omfanget. [Kontakt os](/kontakt/) for en uforpligtende snak om jeres behov. --- ### Onboarding URL: https://pinksky.dk/loesninger/onboarding/ Beskrivelse: Onboarding løsninger og implementeringer på Microsoft platformen. En god onboarding er vigtig, ikke kun for virksomheden, men også for medarbejderen så de føler sig godt modtaget. Ansættelse af nye medarbejdere igangsætter en masse processer, både før den nye medarbejder starter, men også efter medarbejderen er startet. Har man ikke en god onboarding procedure kan man ende med at IT først får besked fredag, at en ny medarbejder starter mandag. Receptionisten har ikke fået gjort adgangskortet klar, der er ikke bestilt blomster, og man glemmer at udlevere personalehåndbogen. ## Onboarding begynder allerede ved underskriften Med en digital onboardingløsning bygget på SharePoint Online, kan du give nye medarbejdere en god start, og samtidig sikre at de har alt hvad de skal bruge. Du kan digitalisere din personalehåndbog, og invitere dem ind til at læse den, allerede inden de starter. Så føler de sig velforberedte og bedre klar til at tage hul på deres nye arbejde. Derudover kan du også benytte platformen til at indsamle de nødvendige oplysninger og dokumenter, som der er behov for, inden de starter. Det kan være alt fra kontaktoplysninger, til bankoplysninger og underskrift af dokumenter. ## Få styr på onboarding processen Udover at få styr på medarbejderoplevelsen ved onboarding, kan Microsoft 365 også hjælpe dig med at få styr på alle de mange opgaver der skal klares. Når en ny medarbejder skal starte, så udfyldes blot en formular med de nødvendige oplysninger. Dette starter en række automatiserede opgaver som eksempelvis en automatisk mail til direktionen om at en ny medarbejder starter. Der oprettes en ny ticket, om at have en computer klar, hos IT. HR afdelingen bliver informeret om medarbejderstart så de kan forberede et velkomstmøde. Resten af organisationen får automatisk besked om den nye medarbejder, og receptionisten ved at der starter en ny på mandag. Med andre ord, så kan du automatisere hele onboarding processen, og sikre at alle de små opgaver bliver klaret, uden at en leder skal huske alle de små ting, og uden at der er risiko for at nogen eller noget bliver glemt. ## Onboarding processens trin En god onboarding proces starter længe før første arbejdsdag, og slutter først, når den nye medarbejder er helt landet. De fleste onboarding forløb kan deles op i fire trin: 1. **Preboarding - fra underskrift til første dag.** Kontrakten er underskrevet, og de praktiske oplysninger indsamles. IT bestiller computer og opretter konti, og den nye medarbejder får adgang til personalehåndbogen og praktisk information om første dag, allerede inden de starter. 2. **Første dag.** Adgangskortet ligger klar, computeren virker, og kollegerne ved, at der starter en ny. Dagen er planlagt, så medarbejderen føler sig ventet. 3. **De første uger.** Introduktion til systemer, processer og kolleger - gerne efter en fast introplan, så intet bliver glemt, og medarbejderen ved, hvad der venter, og lederen kan følge med i processen. 4. **Opfølgning.** Planlagte opfølgningssamtaler efter eksempelvis én og tre måneder sikrer, at både medarbejder og leder får justeret forventningerne, inden små problemer bliver store. ## Fra onboarding skema til automatiseret proces Mange virksomheder styrer i dag deres onboarding med et skema i Excel eller en tjekliste på papir. Det er et fint sted at starte - men skemaet skal huskes, opdateres og følges op manuelt, hver eneste gang, og sedler bliver væk, og mails overset, særligt i travle perioder. Med en onboarding løsning på Microsoft 365 bliver tjeklisten levende: Opgaverne oprettes automatisk, de ansvarlige får besked, og lederen kan altid se, hvor langt processen er, og om nogen skal rykkes. ## Hvad er onboarding? Onboarding er hele forløbet fra en ny medarbejder skriver under, til vedkommende er faldet godt til og bidrager for fuld kraft - både det praktiske (udstyr, adgange, adgangskort) og det menneskelige (velkomst, introduktion, opfølgning). En god onboarding styrker både trivsel og produktivitet fra dag ét. --- ## Hvor lang tid bør et onboarding forløb vare? Længere end de fleste tror. Selve opstarten er intens, men et godt forløb strækker sig typisk over de første tre til seks måneder med planlagte opfølgninger undervejs - det er nemlig i de måneder, fastholdelsen af den nye medarbejder bliver afgjort. --- ## Kan løsningen også bruges til offboarding? Ja. Det samme fundament - formular, automatiske opgaver og besked til de ansvarlige - fungerer lige så godt, når en medarbejder stopper: nedlukning af adgange, indlevering af udstyr og overdragelse af opgaver. Det kan også genbruges, når en medarbejder skifter rolle internt. --- ## Hvad koster en onboarding løsning? Løsningen bygger på SharePoint og Power Automate, som er inkluderet i de fleste Microsoft 365 abonnementer - så investeringen ligger i opsætningen, ikke i nye licenser. [Kontakt os](/kontakt/) og hør, hvad det vil kræve hos jer. --- ### Projektstyring URL: https://pinksky.dk/loesninger/projektstyring/ Beskrivelse: Få overblik over alle jeres projekter med en projektstyringsløsning bygget på Microsoft 365, SharePoint, Teams og Power BI. I langt de fleste organisationer findes der projekter, som eksempelvis salgsprojekter, interne udviklingsprojekter, eller kundeprojekter. Men har I overblikket over alle jeres projekter? Kender I altid seneste status? Har I alle de informationer der hører til et specifikt projekt samlet ét sted? ## Projektstyring med Microsoft platformen Med en projektstyringsløsning bygget på Microsoft platformen, kan du få overblik over hele processen. Fra første gang man møder kunden, til projektet er afsluttet og evalueret. Man kan skabe overblik på tværs af alle projekter, både som medarbejder, men også som leder. Med værktøjer som Power BI kan man opbygge rapporter og dashboards, der giver overblik over projekterne, og hjælper med at træffe de rigtige beslutninger. ## Projektstyring i SharePoint og Teams Udover overblikket over projekterne, kan man også integrere med Microsoft Teams. Det gør det lettere for medarbejderne at samarbejde og dele informationer. Platformen giver også mulighed for at rettighedsstyre adgang til information og data. Det gør at kun de medarbejdere der har behov for adgang får det. **Tilgængeligt, søgbart, og sikkert.** Med [MailConnect](https://getmailconnect.com/da/) kan vi integrere med jeres projektstyringsløsning, så alle mails og vedhæftninger der hører til et projekt, kan gemmes direkte i projektstyringsløsningen, og dermed være tilgængelige for alle medarbejdere der arbejder på projektet. ## Hvorfor bygge projektstyring på Microsoft platformen? Der findes utallige projektstyringsværktøjer og projektstyringssoftware på markedet, men de fleste kræver nye licenser - og betyder, at jeres projektdata lever i endnu et system uden for jeres kontrol. Bygger man i stedet sin projektstyring på SharePoint, Teams og Power BI, får man: - **Ingen nye licenser** - værktøjerne er inkluderet i de fleste Microsoft 365 abonnementer - og de ekstra licenser der måtte være nødvendige er lettilgængelige (fx Power BI). - **Data bliver i huset** - projektdokumenter og -data ligger i jeres eget miljø, omfattet af jeres eksisterende sikkerhed og login-system. - **Et fælles sprog** - alle projekter oprettes og styres på samme måde, så både medarbejdere og ledelse altid kender status, i ét enkelt overblik. - **Skræddersyet til jeres proces** - løsningen tilpasses jeres måde at arbejde på, ikke omvendt. Vil du vide mere om mulighederne, har vi skrevet et blogindlæg om [projektstyring med Microsoft platformen](/blog/2026-07-20-projektstyring-med-microsoftplatformen/). ## Kan Microsoft 365 bruges til projektstyring? Ja. SharePoint eller Dynamics 365 håndterer projektoverblik og dokumenter, Teams samler samarbejdet, Planner tager opgavestyringen, og Power BI leverer rapporteringen. Vi sætter byggeklodserne sammen, så de passer til jeres måde at arbejde på. --- ## Hvad med Microsoft Project - er det ikke Microsofts projektværktøj? Microsoft Project er et specialiseret værktøj til detaljeret projektplanlægning med Gantt-kort og ressourcestyring, og det kræver egne licenser. Microsoft har desuden valgt at droppe Project Online til fordel for Planner Premium, så det kun er enkelte medarbejdere der skal have en ekstra licens, og de fleste kan nøjes med de værktøjer der allerede er inkluderet i Microsoft 365. --- ## Kan projektkorrespondance gemmes i løsningen? Ja. Med [MailConnect](https://getmailconnect.com/da/) gemmer medarbejderne mails og vedhæftninger på det rigtige projekt direkte fra Outlook, med få klik - så ingen skal huske at videresende eller journalisere manuelt, og korrespondancen ikke ligger gemt i personlige indbakker. --- ## Hvad koster en projektstyringsløsning? Det afhænger af omfanget - fra et simpelt projektoverblik til en komplet projektportal med rapportering. Fordi løsningen bygger på jeres eksisterende Microsoft 365 licenser, ligger investeringen i opsætningen. [Kontakt os](/kontakt/) for et konkret bud. --- ## Om Pinksky ### Karriere URL: https://pinksky.dk/om/karriere/ Beskrivelse: Bliv en del af Pinksky - vi søger altid Microsoft 365 og Power Platform specialister med passion for teknologi. Send en uopfordret ansøgning. Hos Pinksky er vi altid på udkig efter ligesindede entusiaster, der deler vores passion for teknologi - og ønsket om ikke bare at levere fremragende løsninger, men at hjælpe vores kunder på deres rejse mod succes med Microsoft-platformen ## Der er altid plads til én mere i sofaen Hvis du er interesseret i at blive en del af vores team, er du velkommen til at sende en uopfordret ansøgning via vores [kontakt side](/kontakt/), Du kan også følge os på [LinkedIn](https://www.linkedin.com/company/pinksky-dk/) for at holde dig opdateret om nye stillinger og muligheder. Sammen med din ansøgning må du meget gerne inkludere links til tidligere projekter, GitHub-repositories, blogindlæg eller andet relevant materiale, der giver os indblik i din erfaring og ekspertise. Vi glæder os til at høre fra dig - og måske byde dig velkommen i Pinksky-familien! --- ### Open source URL: https://pinksky.dk/om/open-source/ Beskrivelse: Pinkskys engagement i open source community og vidensdeling gennem konferencer og projekter. Vi ved det godt: “open source” kan lyde nørdet, og som endnu et smart buzzword. Men for os er det langt mere end det - det er en del af vores identitet. Vi tror på at samarbejde og deling af viden er nøglen til innovation og vækst. Derfor bruger vi ikke bare open source - vi bidrager også til fællesskabet gennem egne projekter, community-events og vidensdeling. Vores [team](/om/teamet/) har et højt engagement i at dele deres viden, og følger du vores [blog](/blog/) vil du finde løsninger, tips og best practices med personlige erfaringer fra Microsoft platformen. Vores medstifter Dan en del af teamet bag [Microsoft 365 & Power Platform communitiet](https://aka.ms/community/home), og hjælper med at facilitere rammerne, så andre kan lære, dele og udvikle sig. For os handler open source ikke kun om at bruge - men også om at bidrage. Det er en grundpille i den måde vi arbejder, lærer og udvikler os som virksomhed. ## Konferencer Vi deltager i konferencer, der giver værdi både for os og vores kunder. Både for at høre hvad der rører sig i branchen, men også for at være opdateret på hvad fremtiden bringer. Vi er ikke kun med som deltagere, men bidrager også som talere, hvor vi er med til at sørge for at dele vores viden og erfaring med andre. ## Badges Vores engagement i open source og community arbejde bliver anerkendt gennem badges. Disse badges har vi ikke fået gennem kurser, eller certificeringer, men som anerkendelse for vores bidrag til open source fællesskabet. --- ### Teamet URL: https://pinksky.dk/om/teamet/ Beskrivelse: Mød Pinksky teamet - kompetente specialister inden for Microsoft 365 og digitale løsninger. Hos Pinksky har vi ikke Danmarks største konsulent team - tværtimod. Vi har bevidst samlet et lille hold af kompetente, kompromisløse og dedikerede specialister, der brænder for at arbejde med IT, og skabe værdi for vores kunder. ## Karriere Vi er altid på udkig efter dygtige folk, der kan styrke vores team. Hvis du er interesseret i at arbejde hos Pinksky, så send os dit CV vores vej.   --- ### Teknik URL: https://pinksky.dk/om/teknik/ Beskrivelse: Pinkskys hjemmeside er bygget på Hugo, Hugobricks og Front Matter CMS - se de open source-projekter og værktøjer vi bruger. Vi fortæller stolt om hvor glade vi er for at arbejde med open source, og community projekter, og det gør vi fordi vi faktisk bruger en hel del open source projekter i vores løsninger. Det er vigtigt for at være transparent omkring hvornår vi står på skuldrene af andre. Herunder kan du se en liste over de open source projekter og andre tredjeparts løsninger vi bruger til at bygge vores hjemmeside, og som gør det nemmere for os at vedligeholde den. ## Open source projekter - [Hugo](https://gohugo.io/) - Vores hjemmeside er bygget med [Hugo](https://gohugo.io/). Det er en hjemmeside generator, som gør det nemmere for os at udbygge hjemmesiden. - [Hugobricks](https://github.com/jhvanderschee/hugobricks) - I kombination med Hugo benytter vi temaet [hugobricks](https://github.com/jhvanderschee/hugobricks). Det er et open source tema med stor fleksibilitet. - [Front Matter CMS](https://github.com/estruyf/vscode-front-matter) - Et "headless CMS System" der kører direkte i Visual Studio Code, som gør det nemmere at holde overblik over hvilke sider der findes på vores hjemmeside, oprette nyt indhold, og holde det eksisterende opdateret. - [Github Actions](https://github.com/features/actions) - Vores side er fuldautomatiseret ved hjælp af [Github Actions](https://github.com/features/actions) - [FTP-Deploy-Action](https://github.com/SamKirkland/FTP-Deploy-Action) gør det meget nemt for os automatisk at uploade ændringer til hjemmesiden. - [Actions-hugo](https://github.com/peaceiris/actions-hugo) gør det muligt at bygge hjemmesiden automatisk i GitHub, uden at vi skal arbejde med koden lokalt først. ## Billeder og video - [Nanna Christensen](https://www.instagram.com/nanna_trab/) - Nanna har været vores fotograf og har taget billederne af os og vores kontor, som bruges på hjemmesiden. - [RR3D](https://www.rr3d.dk/) - Tak til RR3D for videoen fra vores kontor, som du kan se på forsiden. --- ### Vores historie URL: https://pinksky.dk/om/vores-historie/ Beskrivelse: Historien bag Pinksky - tre teknologi-entusiaster med visionen om et konsulenthus, hvor kvalitet, gennemsigtighed og samarbejde er styrende. ## Én vision: > "Alt der kan automatiseres, skal automatiseres. Men aldrig den menneskelige relation." Tre teknologi‑entusiaster, én fælles erkendelse: Teknologi kan meget - men det er mennesker og relationer der gør forskellen, når idéer skal blive til løsninger. Vi havde alle tre arbejdet i stærke faglige miljøer og lært af dygtige kolleger. Vi havde set store projekter lykkedes - men vi havde også set tilbagevendende mønstre: unødvendigt komplekse løsninger, viden i siloer, og fokus der nogle gange gled fra resultat til proces. Det ville vi gerne gøre anderledes. ## Vores svar: Pinksky Vi ville skabe et konsulenthus, hvor kvalitet, gennemsigtighed og samarbejde er de styrende principper. Et sted hvor projektets værdi er vigtigere end antallet af timer - og hvor relationen er vigtigere end teknologien. ### Hvorfor “Pinksky”? Efter utallige navneidéer landede vi på Pinksky: et billede på klarhed, lethed og udsyn. Vi vil gøre cloud‑rejsen lige så problemfrit som at svæve på en lyserød sky - uden tåge, uden mystik, og med overblik hele vejen. ## “in IT together” **in IT together** opsummerer vores tilgang. Digitalisering lykkes, når teknologien er stærk - men samarbejdet er stærkere. Vi ser os ikke som leverandører, men som sparringspartnere, der tager medejerskab i løsningen og resultatet. ## Vores principper - **Open source & fællesskab** | Vi bygger på det bedste, der findes - og vi bidrager også den anden vej. Viden vokser, når den deles. - **Ærlighed & gennemsigtighed** | Klare forventninger, klare estimater og klare argumenter. Vi siger til, når der er en bedre vej. - **Kvalitet uden kompromiser** | “Godt nok” er ikke målet. Vi bygger løsninger, I kan eje, forstå og vokse med. - **Mennesker før processer** | Vi automatiserer det der bør automatiseres - så tiden i stedet kan bruges på relationer, beslutninger og ansvar. ## Resten er historie Pinksky udsprang af ønsket om at hæve barren for, hvordan man leverer teknologi med mennesket i centrum. Vi bygger på de miljøer, der har formet os - og vælger aktivt klarhed, ordentlighed og samarbejde som vores fundament. Vi er klar til at tage rejsen sammen med jer - in IT together. --- ## Team ### Jeppe Spanggaard Rolle: Rådgivende konsulent, Partner Website: https://jeppe-spanggaard.dk/ LinkedIn: https://www.linkedin.com/in/jeppe-spanggaard/ Jeppe har en solid baggrund som in-house udvikler, fra produktmiljøer og som konsulent. Den erfaring gør ham skarp til at bygge løsninger, der både er skalerbare, driftssikre og elegante at vedligeholde. Han har arbejdet med integrationer til et hav af systemer - og er kendt for at skabe løsninger, der holder i praksis og kan vokse med forretningen. --- ### Dan Toft Rolle: Rådgivende konsulent, Partner Anerkendelse: Microsoft Most Valuable Professional (MVP) - https://mvp.microsoft.com/en-US/MVP/profile/794362fc-016e-4794-b801-4f42d95824b0 Website: https://dan-toft.dk/ LinkedIn: https://www.linkedin.com/in/dan-toft/ Dan har siden 2018 arbejdet med Microsoft Platformen. Med kernefokus på brugergrænsefladetilpasninger, automatiseringer og integrationer, har han hjulpet en lang række virksomheder med at optimere deres arbejdsgange og processer. Dan er en meget aktiv deltager i Microsoft fællesskabet, og har siden starten af 2024 været anerkendt som Microsoft MVP. Som en af få danskere har han et tæt samarbejde med Microsoft, der giver ham en unik indsigt i platformens muligheder og fremtid. --- ### Alexander Henkel Rolle: Rådgivende konsulent, Partner Website: https://alexanderhenkel.dk/ LinkedIn: https://www.linkedin.com/in/alexander-henkel-b3ba1721/ Alexander har siden 2001 arbejdet sig op fra gulvet i en større dansk produktionsvirksomhed, og bidrager derfor med en helt unik indsigt i hvordan virksomheder fungerer. Han har siden 2020 brugt den viden til at bygge IT løsninger der hjælper med at optimere og automatisere virksomheders processer. --- ## Blog Pinksky udgiver regelmæssigt tekniske artikler om Microsoft 365, Power Platform, SharePoint Online, Power BI og tilhørende teknologier skrevet af virksomhedens egne konsulenter. Blog: https://pinksky.dk/blog/ ### Dynamics 365 Sales eller jeres eget CRM - køb eller byg? Forfatter: Pinksky Dato: 2026-10-13 URL: https://pinksky.dk/blog/2026-10-13-dynamics-365-sales-eller-eget-crm/ Når en virksomhed vil have styr på salget, begynder jagten som regel på et CRM-system - et fælles sted til kunderne, kontaktpersonerne og de salgsmuligheder, der ellers ligger i hver sælgers indbakke og hoved. Arbejder I allerede på Microsoft platformen, er der tre veje til det. I kan købe Dynamics 365 Sales, som er Microsofts eget færdige CRM. I kan købe et færdigt CRM-produkt fra en anden leverandør, bygget oven på platformen. Eller I kan bygge et CRM efter jeres egne behov. Vi anbefaler som regel at starte enkelt og bygge videre, og resten af indlægget handler om, hvornår det råd holder - og hvornår det ikke gør. Dynamics 365 Sales: I køber en komplet løsning Dynamics 365 Sales er et omfattende CRM-system, der er bygget til organisationer med mange brugere, komplekse salgsprocesser og behov for en lang række funktioner. Det er et gennemarbejdet produkt, men vi ser ofte, at virksomheder kun bruger en mindre del af funktionaliteten. Ikke fordi funktionerne er dårlige, men fordi de ikke passer til den måde, virksomheden arbejder på. For en sælger skal CRM-systemet først og fremmest være nemt at bruge. Hvis det kræver for mange klik og felter at registrere et opkald eller følge op på en kunde, bliver det hurtigt en opgave, man forsøger at undgå. Resultatet er et CRM med mangelfulde data - selvom systemet egentlig kan langt mere. Og selvom Dynamics 365 Sales er et færdigt produkt, skal det stadig tilpasses den enkelte virksomhed. Salgsfaser, felter, visninger, processer og integrationer skal sættes op, så systemet passer til jer. I køber altså en stor løsning og tilpasser den bagefter. Et færdigt CRM-produkt på Microsoft platformen Der findes også en række CRM-produkter fra andre leverandører, som er bygget oven på Microsoft platformen. De kan være et interessant alternativ. De er ofte enklere end Dynamics 365 Sales, hurtigere at komme i gang med og kan have en lavere produktpris. Men det er vigtigt at se på den samlede licenspris. Når løsningen er bygget på Microsoft platformen, skal brugerne typisk også have adgang til selve platformen, og produktlicensen kommer derfor oven i platformens licens. Sammenlign altså ikke kun CRM-produktets pris med Dynamics 365 Sales - se på den samlede pris pr. bruger. Der er også en anden ting at overveje: I køber stadig en løsning, der er designet efter en anden virksomheds måde at arbejde på. Den skal måske tilpasses jeres processer, og I bliver afhængige af leverandørens produkt og videreudvikling. Et CRM, I bygger efter jeres behov Den tredje mulighed er at bygge et CRM direkte på Microsoft platformen. Her starter I med det, I faktisk har brug for: kunder, kontaktpersoner, salgsmuligheder, aktiviteter og de salgsfaser, jeres sælgere arbejder med. I behøver ikke bygge hele CRM-systemet på én gang. Start med det vigtigste, og udvid løsningen, når behovet opstår - det kan for eksempel være tilbud, projekter, integration til økonomisystemet, dokumenthåndtering eller rapportering. I stedet for at købe funktioner og slå dem fra, bygger I kun de funktioner, I har brug for. Til gengæld ejer I også selv løsningen. Det betyder, at nogen skal stå for videreudvikling og vedligeholdelse, enten internt eller sammen med en partner. Det, I får for det, er en løsning, der kan udvikle sig sammen med virksomheden, uden at I nødvendigvis skal skifte CRM-system undervejs. Det er den type skræddersyede løsninger på Power Platform, vi bygger for danske virksomheder. En vigtig fordel er, at I ikke behøver se det som et endeligt valg. Data ligger fortsat på Microsoft platformen, og løsningen kan derfor udvides eller ændres senere. Hvad skal afgøre jeres valg? Start med at se på jeres salgsproces. Hvis I har en relativt klassisk salgsproces med faste faser, tilbud, ordrer og prislister, kan et færdigt CRM være et godt udgangspunkt. Hvis salget derimod involverer projekter, partnerskaber, udbud eller andre processer, der ikke passer så godt ind i en standardiseret salgsmodel, kan en mere tilpasset løsning give bedre mening. Men næsten alle virksomheder skal tilpasse deres CRM. Det interessante spørgsmål er derfor ikke, om systemet skal tilpasses, men hvordan. Skal I tage udgangspunkt i et stort færdigt system og tilpasse det til jer? Eller skal I starte med jeres egen proces og bygge systemet omkring den? Se også på brugerne. Ti sælgere, der arbejder i CRM-systemet hver dag, har andre behov end tre sælgere og 30 kolleger, der kun skal kunne slå en kunde op en gang imellem. Til sidst bør I se på økonomien over flere år - ikke kun hvad løsningen koster at købe, men også hvad licenser, tilpasninger og videreudvikling koster. Og spørg jer selv: Hvem skal udvikle løsningen om to år? Microsoft, en anden leverandør eller jer selv? Der er ikke ét rigtigt svar, men det er et spørgsmål, der er værd at have besvaret, inden I vælger. Hvornår giver Dynamics 365 Sales mening? Vi anbefaler ofte at starte enkelt og bygge videre, fordi et CRM-system først skaber værdi, når det faktisk bliver brugt. Men der er bestemt virksomheder, hvor Dynamics 365 Sales er det rigtige valg. Det kan være, hvis jeres salgsproces ligger tæt på standarden, hvis I har et stort salgsteam, der har brug for mange af systemets funktioner, eller hvis I ønsker, at Microsoft står for en stor del af den løbende udvikling. Her køber I ikke kun funktionerne i systemet. I køber også et etableret produkt, som løbende bliver videreudviklet. Opgaven bliver så at tilpasse Dynamics 365 Sales til jeres salgsproces, så sælgerne får et system, der er enkelt at arbejde i, frem for at blive mødt af funktioner, de aldrig bruger. Hvor meget I selv vil stå for, er også op til jer. Har I kompetencerne internt, kan I bygge en del af løsningen selv og bruge os som sparringspartner. Skal vi stå for hele opgaven, kan vi også gøre det. Og hvis I allerede har et CRM-tilbud liggende, kan vi gennemgå det sammen med jer som en uvildig second opinion. Beskriv salgsprocessen, før I vælger CRM Inden I vælger system, er der ét spørgsmål, der er vigtigere end teknologien: Er I enige om, hvordan jeres salgsproces fungerer? Hvornår bliver et kundeemne til en salgsmulighed? Hvem ejer den? Hvilke trin skal den igennem? Hvornår er en mulighed vundet eller tabt? Og hvilke oplysninger har ledelsen faktisk brug for? Hvis I ikke er enige om det, løser et nyt CRM ikke problemet. Et CRM-system kan strukturere en salgsproces, men det kan ikke skabe en fælles proces for jer. Derfor kan det være en god idé at starte med at få salgsprocessen beskrevet, inden I vælger system, for eksempel gennem uvildig rådgivning. Og det hænger direkte sammen med vores anbefaling om at starte enkelt: Den første version af jeres CRM bør løse de behov, I allerede kender. Derefter kan I bygge videre. Hos Pinksky hjælper vi virksomheder med at vælge mellem Dynamics 365 Sales og et CRM bygget på Microsoft platformen - og med at bygge den løsning, der passer til jeres måde at arbejde på. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### Three Base64 Blobs and a Null Token: Decrypting Graph Rich Notifications Forfatter: Jeppe Spanggaard Dato: 2026-10-05 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-10-graph-rich-notifications-validate-decrypt/ Microsoft Graph rich notifications are great: the webhook payload carries the resource data itself, so you can act on a change without calling back. What the docs undersell is that the payload arrives as three base64 blobs (data, dataKey, dataSignature) plus a set of JWTs, and undoing that envelope is entirely your job. My first attempt got a null where the JWTs should be, and my second got ciphertext I couldn’t decrypt. Both failures were one configuration flag and one crypto convention away from working, so here’s the whole pipeline in one place. First, Prove It’s Microsoft Anyone who discovers your notification URL can POST to it. Rich notification batches ship signed JWTs in validationTokens, and all of them must validate before you touch the content: var configManager = new ConfigurationManager( $"https://login.microsoftonline.com/{tenantId}/v2.0/.well-known/openid-configuration", new OpenIdConnectConfigurationRetriever()); var parameters = new TokenValidationParameters { ValidAudiences = new[] { clientId, "https://graph.microsoft.com" }, ValidIssuers = new[] { $"https://login.microsoftonline.com/{tenantId}/v2.0", $"https://sts.windows.net/{tenantId}/" // tokens still show up v1-style }, ConfigurationManager = configManager // fetches + caches signing keys }; foreach (string token in validationTokens) { var result = await new JsonWebTokenHandler().ValidateTokenAsync(token, parameters); if (!result.IsValid) return false; // one bad token fails the batch } On top of that, check that each notification’s clientState equals the secret you set at subscription time. And when a batch fails validation, still answer 202 - you’re telling Graph “delivered”, not “approved”, and a 4xx just buys you a retry storm of the same bad batch. Then, Undo the Envelope Graph encrypts each payload with a fresh AES key, and encrypts that key with the public certificate you provided when subscribing. Your job is the reverse, in exactly this order: public string Decrypt(string data, string dataKey, string dataSignature, X509Certificate2 cert) { byte[] encryptedKey = Convert.FromBase64String(dataKey); byte[] encryptedData = Convert.FromBase64String(data); byte[] expectedSignature = Convert.FromBase64String(dataSignature); // 1. Recover the single-use AES key with our private key. using RSA rsa = cert.GetRSAPrivateKey()!; byte[] symmetricKey = rsa.Decrypt(encryptedKey, RSAEncryptionPadding.OaepSHA1); // 2. Verify integrity BEFORE decrypting. using (var hmac = new HMACSHA256(symmetricKey)) { byte[] actual = hmac.ComputeHash(encryptedData); if (!CryptographicOperations.FixedTimeEquals(actual, expectedSignature)) throw new CryptographicException("Data signature mismatch."); } // 3. AES-CBC-PKCS7. The IV is the first 16 bytes of the key itself. using var aes = Aes.Create(); aes.Key = symmetricKey; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.PKCS7; aes.IV = symmetricKey[..16]; using var decryptor = aes.CreateDecryptor(); byte[] plaintext = decryptor.TransformFinalBlock(encryptedData, 0, encryptedData.Length); return Encoding.UTF8.GetString(plaintext); // the resource JSON } What’s happening here? dataKey is the AES key, RSA-encrypted with your cert’s public key. OAEP padding, and each notification item can carry a different key - never cache it. The HMAC check runs on the encrypted bytes, before decryption. If someone tampered with the ciphertext, you find out without feeding attacker-controlled bytes to your AES code. FixedTimeEquals keeps the comparison from leaking timing. There’s no separate IV field anywhere in the notification. The convention is that the first 16 bytes of the symmetric key double as the IV - miss that and you get either garbage or a padding exception, with nothing pointing at the cause. The result is plain JSON for the resource, shaped by whatever $select you put on the subscription. The Null Token Trap My actual first failure: validationTokens was null on every batch, so nothing could ever validate. The cause is an Entra setting nowhere near your webhook code. For rich notifications, the app’s service principal must have appRoleAssignmentRequired = false (Enterprise applications, your app, Properties, “Assignment required?” = No). Alternatively, keep it on and explicitly assign an app role to the Microsoft Graph Change Tracking service principal (appId 0bf30f3b-4a52-48df-9a82-234910c4a086). Do neither, and Graph silently sends null tokens and your endpoint rejects every batch it was built to receive. Gotchas Two things are named “validation token”. The plain-text validationToken query parameter you echo back during the subscription handshake, and the JWT array validationTokens on every rich batch. Different formats, different purposes, one letter apart. Basic notifications have no tokens at all. If the same endpoint also receives non-rich subscriptions, don’t demand JWTs from those batches; there clientState is your only check. Plan for two certificates during rotation. Old subscriptions keep encrypting with the old cert for a while. Pick the cert by each notification’s encryptionCertificateId instead of assuming the newest, and retire the old one only when it stops appearing. The cert can be self-signed. Graph only uses the public key for encryption and never checks the issuer. RSA, 2048 to 4096 bits. Wrapping Up Treat a rich notification as untrusted input until it has passed three gates: JWTs validated, clientState matched, HMAC verified - and only then decrypt (RSA-unwrap, then AES-CBC with the key’s first 16 bytes as IV). And if the tokens come back null, stop debugging your code and go flip “Assignment required” on the service principal. --- ### Integrationsplatform: hvorfor det betaler sig at samle jeres data ét sted Forfatter: Pinksky Dato: 2026-09-28 URL: https://pinksky.dk/blog/2026-09-28-integrationsplatform/ En integrationsplatform er det sted, hvor data fra jeres forskellige systemer bliver bundet sammen og gjort tilgængelige for dem, der skal bruge dem. Det egentlige spørgsmål er derfor sjældent, om to systemer skal tale sammen, men hvor jeres data skal samles henne. For de fleste danske virksomheder, der i forvejen arbejder i Microsoft 365, er vores svar den platform, I allerede har - med et vigtigt forbehold om, hvilke data der slet ikke skal derind. Hvad er en integrationsplatform? De fleste kender følelsen af at taste det samme ind to steder. Ordren ligger i ERP-systemet, kunden i CRM-systemet, og sagen i SharePoint, og en medarbejder bruger en del af ugen på at holde de tre enige med hinanden. Den første reaktion er at bygge en forbindelse mellem to af dem, og det virker fint. Problemet kommer, når forbindelse nummer fire og fem skal bygges, og hvert system nu taler direkte med en håndfuld andre. Så er der ikke længere nogen, der har overblikket over, hvad der sendes hvorhen, og et systemskifte betyder, at alle forbindelserne skal bygges om. En integrationsplatform vender tankegangen om. I stedet for at alle systemer taler med alle, bliver data samlet ét sted - en “hub” - som systemerne kobler sig på, og som medarbejderne arbejder ud fra. Det er den forskel, vi beskriver som punkt-til-punkt eller et fælles mønster på siden om systemintegration og integrationsmønstre, og målet er det samme: én version af sandheden i stedet for tre versioner, der næsten stemmer. Hvorfor Microsoft platformen er et godt sted at samle data Der findes glimrende selvstændige integrationsprodukter, og de kan flytte data mellem systemer lige så godt som noget andet. Har I mange systemer med store datamængder, der skal synkroniseres i realtid, og et team, der har integrationer som sit fag, kan det være det rigtige køb. Men de lægger data uden for huset, med deres egen brugerdatabase, deres egen adgangsstyring og deres egen regning. Det bærende argument for Microsoft platformen er, at I sandsynligvis allerede har den. Data, der samles i jeres Microsoft 365, ligger et sted, medarbejderne allerede har adgang til med det login, de bruger hver dag. Rettighedsstyringen er allerede sat op og følger de brugere og grupper, I allerede administrerer. Søgningen kan allerede finde dem. Og Microsoft 365 Copilot kan bruge dem inden for den enkelte medarbejders adgange, uden at der først skal bygges og betales for en forbindelse til et andet system. Ligger de samme data i et selvstændigt system, skal den forbindelse etableres og vedligeholdes, før Copilot overhovedet ved, at de findes. Platformen kan også allerede nå de fleste af jeres andre systemer. Power Automate, som er en del af Microsofts Power Platform og følger med de fleste Microsoft 365 abonnementer, kommer med færdige forbindelser til et meget stort antal systemer - fra ERP- og CRM-systemer til økonomisystemer, webshops og fagsystemer. Og hvor der ikke findes en færdig forbindelse, kan data som regel hentes på anden vis, for eksempel via systemets egen snitflade, en fil, det eksporterer, eller en mail, det sender. Den første integration starter derfor sjældent med et nyt produkt, men med et værktøj, I allerede har. Nogle af forbindelserne kræver en tillægslicens, så det er værd at få afklaret, inden I lægger jer fast. Det er med andre ord ikke et spørgsmål om, hvilken teknologi der er bedst til at flytte data. Det er et spørgsmål om, hvor jeres data lever henne, når de er flyttet - og hvem der ejer dem den dag, et abonnement stopper. Ét fælles datagrundlag er ikke et lager til alt Her er det vigtigste forbehold, og det skal siges, inden nogen læser det ovenfor som “smid alt ind i SharePoint”. Et fælles datagrundlag er det sted, data bliver bundet sammen og gjort tilgængelige for dem, der arbejder med dem. Det er ikke et datawarehouse, hvor man kopierer alt hen for en sikkerheds skyld. ERP-systemet er stadig sandheden om ordren, og lønsystemet er stadig sandheden om lønnen. Hver kopi, ingen har brug for, er data, der kan nå at blive forkerte et sted, hvor ingen kigger. SharePoint er i særdeleshed et arbejdsområde, ikke et datalager. Løndata er det klare eksempel. Lønudgiften pr. afdeling er relevant for ledelsen, men den har intet at gøre i en SharePoint liste. Ingen skal arbejde i den der, og I har pludselig et sted mere, hvor følsomme oplysninger skal styres og holdes lukket. Hvad skal I så med de tal? De hører til i en rapport. Hvor hører data hjemme: SharePoint, Dataverse eller Power BI? Valget af sted er et forretningsvalg frem for et teknisk valg, og det har tre svar, ikke to: SharePoint er arbejdsområdet. Dokumenter, sager og lister, som medarbejderne selv arbejder i til daglig. Tommelfingerreglen er enkel: skal nogen røre ved data, hører de til her. Det kræver en gennemtænkt struktur, for et SharePoint uden en klar strategi ender som et netværksdrev med et nyt navn. Dataverse er til stamdata. Strukturerede data, som flere løsninger trækker på, og hvor relationerne og valideringen skal holde - kunder, projekter eller udstyr, der går igen i flere apps. Dataverse er en del af Power Platform og kræver tillægslicenser, så det er sjældent der, I starter. Rapportlaget er til data, der kun skal ses. Lønudgifter, omsætningstal og timeregistrering. Power BI henter data direkte fra kildesystemerne og stiller dem sammen med resten, uden at de skal omkring en liste undervejs. Det tredje svar er det, der oftest bliver glemt. Det er også svaret på spørgsmålet “skal vores lønudgifter så ind i SharePoint?” - nej, de skal ind i rapporten. Hvornår betaler en integration sig? Ikke alt skal integreres, og et regneark, der overføres én gang om året, er ikke en integration værd. Regnestykket er enkelt at stille op, selvom tallene er jeres egne: hvor mange gange om måneden sker den manuelle overførsel, hvad koster det, når data står forskelligt to steder og nogen træffer en beslutning på det forkerte tal, og hvem ejer forbindelsen bagefter? Det sidste spørgsmål bliver oftest overset. En integration, som ingen overvåger, kan stå stille i ugevis, før nogen opdager det. Samtidig er det værd at punktere forventningen om, at alt kan integreres med alt. Det afgørende er, om systemet overhovedet stiller sine data til rådighed, og det er noget, I kan spørge jeres leverandør om, allerede inden I går i gang. Svaret afgør mere end valget af platform, og det er derfor, et godt integrationsforløb starter med at kortlægge systemlandskabet frem for at vælge teknologi. Hvordan kommer I i gang? Start med et overblik: hvilke systemer har I, hvor bliver det samme tastet ind to gange, og hvilke data skal medarbejderne arbejde i, frem for bare at se dem? Den kortlægning kan I sagtens lave selv, og mange virksomheder har folk internt, der kan bygge de første forbindelser. Så kan vi hjælpe med sparring undervejs, for eksempel med en uvildig vurdering af jeres systemlandskab, inden der er bygget ti forbindelser, som ingen har tegnet op. Skal vi tage hele opgaven, fra kortlægning til drift, gør vi det lige så gerne. Hos Pinksky bygger vi integrationer på Microsoft platformen for danske virksomheder - så jeres data bliver samlet i huset, med de logins og rettigheder I allerede har. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### Filter Before You Fetch: Graph Mail Notifications That Carry Their Own Metadata Forfatter: Jeppe Spanggaard Dato: 2026-09-25 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-09-graph-mail-rich-notifications-filter-before-fetch/ I built a service that watches a few shared mailboxes and reacts when mail arrives from (or goes to) specific partner addresses. Classic Graph webhook territory: subscribe to the folder, get a notification per message, done. Except a basic Graph notification is nearly empty. You get a message ID and not much else. To find out who sent it, you call Graph. And 99% of the mail in those mailboxes is noise, so I’d be making a GET per message just to conclude “don’t care” - burning through throttling budget to ignore things. The fix is to make the notification itself carry enough data to decide. Graph calls this rich notifications, and combined with $select it turns the webhook payload into a pre-filter. The Subscription Is the Contract Everything is decided at subscription time. The $select on the resource URL is what your notifications will contain, no more, no less: var subscription = new Subscription { ChangeType = "created", NotificationUrl = $"{baseUrl}/api/notifications", LifecycleNotificationUrl = $"{baseUrl}/api/lifecycle", Resource = $"/users/{mailbox}/mailFolders('{folder}')/messages" + "?$select=id,subject,from,toRecipients,ccRecipients," + "receivedDateTime,hasAttachments,importance,internetMessageId", IncludeResourceData = true, EncryptionCertificate = publicKeyBase64, // Graph encrypts the payload with this EncryptionCertificateId = "notifications-2026", ClientState = clientState, ExpirationDateTime = DateTimeOffset.UtcNow.AddHours(23) }; await graph.Subscriptions.PostAsync(subscription); What’s happening here? IncludeResourceData = true upgrades the subscription from “something changed” pings to payloads carrying the selected fields of the message itself. The $select is the whole trick: addressing fields, subject, flags - everything my rule needs, and nothing heavy. Graph refuses $select=Body here; the body can never travel in a notification. Rich payloads arrive encrypted, so you hand Graph a public key at subscription time (a self-signed cert is fine) and keep the private key to decrypt. That’s a post of its own; here it’s one method call. I create one of these per mailbox for inbox and one for sentitems, so both directions of the conversation are covered. The Gate When a notification lands (validated, decrypted - hand-waved here), the payload is just the message JSON with my selected fields. The decision runs entirely on it: string json = decryptor.Decrypt(ec.Data, ec.DataKey, ec.DataSignature, cert); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; string? sender = GetAddress(root, "from"); var recipients = GetAddresses(root, "toRecipients") .Concat(GetAddresses(root, "ccRecipients")).ToList(); if (!policy.ShouldReadBody(sender, recipients)) return; // 99% stop here. Free. // Only a match earns a Graph call - and only for what the payload can't carry. var full = await graph.Users[mailbox].Messages[messageId].GetAsync(rc => { rc.QueryParameters.Select = new[] { "subject", "body" }; rc.Headers.Add("Prefer", "outlook.body-content-type=\"text\""); }); The policy itself is boring on purpose: read the body if the sender or any recipient matches a configured address or domain. One rule works for both folders - on received mail the interesting address is the sender, on sent mail it’s a recipient, and checking all of them covers both without branching on folder. That Prefer header saves a second round of work: Graph converts the body to plain text server-side, so a rule engine or an LLM downstream doesn’t choke on 40 KB of Outlook HTML. What This Buys You With basic notifications, a mailbox receiving 500 mails a day where 5 matter costs 500 GETs. With the payload gate it costs 5. The other 495 decisions happen in memory on data that was pushed to you anyway. It also changes what a throttling incident looks like. Message fetches now correlate with interesting mail volume, not total mail volume, and interesting mail is what your business case already sized for. Gotchas Rich subscriptions live under a day. Basic Outlook subscriptions get up to 7 days; set includeResourceData = true and the max drops to 1440 minutes. You need a renewal loop (a timed PATCH on expirationDateTime) and a lifecycleNotificationUrl for the events that tell you when renewal isn’t enough. The body is not an option. $select=Body,UniqueBody is explicitly rejected on the resource URL, along with $top, $skip, $orderby and most $expand. The payload gate can never be “check if the body contains X” - design the rule around addressing fields and subject. Answer the webhook fast, think later. Graph wants a quick 202; do the decrypt-gate-fetch work on a background queue, not in the request handler. Mail.Read as an application permission is tenant-wide. Scope it down with an Exchange Application Access Policy so the app can only touch the mailboxes it watches. Do this before someone asks in a security review, not after. New messages only. A subscription delivers changes from creation onward. For history, or after a missed lifecycle event, reconcile with a delta query per folder. Wrapping Up Put your filter’s inputs in the subscription’s $select, and the notification becomes the filter. Fetch the full message only for the few that pass. When the data you need to decide is small and the data you need to act is big, let the webhook carry the first and pay an API call for the second only when it’s earned. --- ### Microsoft Graph Term Store Search: The Undocumented Filter Forfatter: Jeppe Spanggaard Dato: 2026-09-20 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-09-graph-term-store-search-filter/ I was building a managed metadata picker into an Office add-in. Nothing exotic: a term set, a popover, a search box, the same thing SharePoint’s own tagging dialog does. The term sets in play run to thousands of terms. The obvious cheap route is to download the term set once and filter it in the browser. I didn’t want that. Waiting for a full load before the first keystroke does anything is a bad experience, and it stops scaling the moment someone points the column at a bigger set. Server-side filtering or nothing. So I went looking for the search endpoint. What I never found was anyone who could point me at one. I Couldn’t Find a Single “Just Do This” I started where I usually start, with the libraries. PnPjs had a searchTerm method for years, built on SharePoint’s taxonomy REST API, and it didn’t come along when v4 moved taxonomy over to Microsoft Graph. Issue #3054 is where that question lands, and the answer there is that Graph’s taxonomy endpoints don’t support search. PnP Core has an open feature request, #892, for term search at store level, so nothing to copy from there either. Next I looked at how Microsoft does it in its own component. The Graph Toolkit’s mgt-taxonomy-picker loads children and narrows them in the browser. That’s the design you land on when the service won’t filter for you, and it’s the design I was specifically trying to get away from. Then the reference docs, hoping for an example I could adapt. The v1.0 term resource type page lists Get term, Create term, Update term, Delete term, List children, List relations. There is no List terms operation. No $filter example appears anywhere in the termStore API docs. Nothing hostile, nothing wrong. Just no map, and no forum answer saying “you want this URL”. There is a supported answer, and it’s SharePoint’s own: _api/v2.1/termStore/searchTerm(label='proc',languageTag='en-US'). I never called it. My add-in holds Microsoft Graph tokens, and that endpoint wants a SharePoint resource token with its own TermStore.Read.All scope on top. Adding a second token flow to a picker is a lot of moving parts for one text box, so I stayed on the API I already had a token for. Which left me poking at the Graph endpoints myself, with no example to copy and no idea whether there was anything to find. Can Microsoft Graph Search the SharePoint Term Store? Yes, and I run it in production. A term set’s terms collection accepts $filter=labels/any(a: startswith(tolower(a/name),'proc')), an OData filter over the whole label collection, and it runs server-side across every level of the set: GET https://graph.microsoft.com/v1.0 /sites/{site-id}/termStore/sets/{set-id}/terms ?$filter=labels/any(a: startswith(tolower(a/name),'proc')) &$select=id,labels &$top=50 Delegated TermStore.Read.All with admin consent is the whole permission story, and delegated is also the only story: every termStore operation I use lists application permissions as “Not supported”, so this is a signed-in-user API whether you’re building a picker or a nightly job. The catch isn’t permissions, it’s that this is undocumented, and that testing it naively will lie to you. A 200 OK Proves Nothing Here Graph’s own query parameter documentation says it plainly: However, sometimes query parameters specified in a request fail silently. For example, for unsupported query parameters and for unsupported combinations of query parameters. In these cases, examine the data returned by the request to determine whether the query parameters you specified had the desired effect. The termStore workload does exactly this, and there’s a well-known example: $expand=children on terms returns 200 OK with the children quietly missing. No error, no hint, just a response that looks fine and isn’t. Here’s the test I nearly settled for. My term set has a root term called “Procurement”. I filtered for Procurement. One term came back, and it was the right one. Proof? No. That’s the exact response you’d get if the service threw your filter away and handed you a slice of the collection that happens to contain what you asked for. A plausible-looking 200 is not evidence when silent-ignore is the documented failure mode. You have to prove the negative. The Control Experiments Eight requests, two Graph Explorer batches, one paste each. JSON batching takes up to 20 requests in a single call, which makes it an underrated way to run a whole experiment matrix at once instead of clicking through it: POST https://graph.microsoft.com/v1.0/$batch { "requests": [ { "id": "1", "method": "GET", "url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/children?$select=id,labels" }, { "id": "2", "method": "GET", "url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/children?$filter=labels/any(a: a/name eq 'zzz_no_such_term')" }, { "id": "3", "method": "GET", "url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/terms?$filter=labels/any(a: startswith(a/name,'Proc'))" }, { "id": "4", "method": "GET", "url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/terms/{procurement-term-id}/children?$select=id,labels" }, { "id": "5", "method": "GET", "url": "/sites/{site-id}/termStore/groups/{group-id}/sets/{set-id}/terms?$select=id,labels&$top=10" } ] } The second batch was the case-sensitivity pair plus a nested-term probe: startswith(a/name,'proc'), startswith(tolower(a/name),'proc') and startswith(a/name,'Shipp'). Here’s what came back, re-run against a live tenant on 2026-09-06: # Request Result What it proves 1 /children, no filter 9 root terms Baseline, so filtered results can be compared to unfiltered 2 /children filtered on a/name eq 'zzz_no_such_term' "value": [] The filter is genuinely applied, not discarded 3 /terms filtered on startswith(a/name,'Proc') 1 term, “Procurement” Prefix filtering works on the flat whole-set collection 4 /children of the Procurement term 5 terms, including “Shipping” Establishes that “Shipping” lives one level down 5 /terms?$top=10 10 terms plus @odata.nextLink carrying $skiptoken=MTA Paging is honored on the flat listing a /terms filtered on startswith(a/name,'proc') "value": [] Plain startswith is case-sensitive b /terms filtered on startswith(tolower(a/name),'proc') 1 term, “Procurement” tolower() gives you case-insensitive search c /terms filtered on startswith(a/name,'Shipp') 1 term, “Shipping” The flat search reaches nested terms, not just the top level Two of these carry the whole argument. Request 2, filtering /children for a label that doesn’t exist and getting [] back, is the only thing standing between “the filter works” and “the service ignored me politely”. Request 4, listing the children of the Procurement term, is what turns the nested hit into a claim about depth: “Shipping” is a child of “Procurement”, it isn’t in the root listing, and the flat /terms filter found it anyway. One path detail, since I’m making a fuss about proof. The batch above addresses the set through /termStore/groups/{group-id}/sets/{set-id}, because that’s the shape Graph Explorer left me with. The documented path for a set is the shorter /termStore/sets/{set-id}, no group segment, and both reach the same set. The picker below uses the short one, which is also the form running in production. The Picker Query This is the search path from the add-in, trimmed to the parts that matter: /** Result cap for server-side type-ahead queries. */ const TYPEAHEAD_MAX_RESULTS = 50; /** Escape single quotes for an OData string literal. */ function odataQuote(value: string): string { return value.replace(/'/g, "''"); } function defaultLabel(term: GraphTerm): string { return term.labels?.find(l => l.isDefault)?.name ?? term.labels?.[0]?.name ?? term.id ?? ''; } async searchTerms(siteId: string, termSetId: string, query: string): Promise { const filter = encodeURIComponent( `labels/any(a: startswith(tolower(a/name),'${odataQuote(query.trim().toLowerCase())}'))` ); const res = await this.graphFetch( `/sites/${siteId}/termStore/sets/${termSetId}/terms` + `?$filter=${filter}&$select=id,labels&$top=${TYPEAHEAD_MAX_RESULTS}` ); return asCollection(res) .filter(t => !!t.id) .map(t => ({ id: t.id!, label: defaultLabel(t) })); } What’s happening here? The query is lowercased on the client and the stored label is lowercased by tolower() inside the filter. Both halves are required. Lowercase input against a plain startswith is experiment (a): an empty array, for a term that exists. odataQuote doubles single quotes, because a user typing O'Brien would otherwise close the string literal and hand the service a broken filter. encodeURIComponent on the whole filter value isn’t optional either. A # in the query truncates the URL at the fragment, and the request you send stops being the request you wrote. $select=id,labels keeps the payload small. Without it you also get createdDateTime, lastModifiedDateTime and descriptions on every hit, which a picker has no use for. $top=50 caps the type-ahead. Nobody scrolls past 50 suggestions, and the cap stops a two-character query from dragging half the set across the wire. In the component, the input is debounced 300 ms and search only fires from 2 characters. Browsing is a separate path: the tree loads one level at a time through /termStore/sets/{set-id}/children and /terms/{term-id}/children, paged at 50, so opening the picker on a huge set costs one small request instead of the whole taxonomy. Gotchas Prove the negative on your own tenant before you ship this. One request with a nonsense label that must come back empty. Silent-ignore is the documented behavior of this workload, so that empty array is your only proof, and it costs nothing to keep as a test. Flat results carry no ancestry. A hit gives you id and labels, no parent path, no way to scope the filter to a subtree. Anchor-bound columns therefore can’t use it. In my add-in they take a different route: load the (usually small) subtree with a bounded walk and filter client-side, with a visible note when the walk gets truncated. $expand=children is one of the parameters that gets ignored. You can’t cheapen the tree loading that way, and it won’t tell you it failed. v1.0 has no childrenCount. A lazy tree can’t know whether a node is expandable without fetching it. Render every node as a branch, then demote it to a leaf once its expansion comes back empty. Custom sort order isn’t exposed. Graph returns terms alphabetically. If a term set relies on the custom ordering the SharePoint UI shows, your picker won’t match it, and someone will notice. Watch your HTTP client for the paging link. I ended up calling these endpoints with a plain fetch, because the client library I use unwraps value and drops @odata.nextLink on the floor. No nextLink, no paging. Non-default labels match too. The filter runs over the whole labels collection, so a synonym can produce the hit. Display the term’s isDefault label, or users will pick a term whose name they never typed. This is undocumented. It isn’t a preview flag or a beta endpoint, it’s real OData surface with no documented list operation, and Microsoft owes you nothing if it changes. Make failure visible in the UI rather than silently degrading to something worse. Credit where it belongs: the same labels/any filter shape turns up in a 2022 post on getting terms by label, aimed at Power Automate flows against SharePoint’s _api/v2.1/termStore. That’s the SharePoint REST side of the fence. The question I needed answered was whether it survives on graph.microsoft.com/v1.0 with a plain Graph token and nothing else. It does. Wrapping Up Microsoft Graph can search the term store. GET /sites/{site-id}/termStore/sets/{set-id}/terms?$filter=labels/any(a: startswith(tolower(a/name),'proc')), delegated TermStore.Read.All, server-side, all depths, undocumented. Wrap it in a debounce and you have a type-ahead picker that doesn’t download a taxonomy first. The rule of thumb I’d rather you take away is the testing one: when a workload is documented to ignore parameters it doesn’t like, a 200 OK full of sensible-looking data is not evidence of anything. Ask it for something that cannot exist. If you get rows back, your filter was never running. If you’re weighing up which API to reach for on SharePoint work in general, CSOM vs SharePoint REST vs Graph is my pick-one list, including the metadata gaps Graph won’t tell you about. And for the other end of the taxonomy problem, reading terms off list items fast, there’s Load SharePoint Taxonomy Fields Fast With FieldValuesForEdit. --- ### Break Graph on Purpose: Testing Durable Functions Provisioning with Dev Proxy Forfatter: Jeppe Spanggaard Dato: 2026-09-15 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-09-testing-durable-functions-provisioning-dev-proxy/ I’ve written a small series about making SharePoint provisioning with Durable Functions resilient: checkpointing so runs resume instead of restart, idempotent activities that survive reruns, failing fast on permanent errors, deterministic instance IDs against duplicate starts, and waiting out Graph’s eventual consistency. Here’s the uncomfortable part. All of that is code that only runs when Microsoft’s servers have a bad day. I had tested the happy path a hundred times. The unhappy path: zero. You can’t ask Graph to please throw a 429 at step four. So the retry ladder, the idempotency guards, the fail-fast logic - a good chunk of it had never executed outside my imagination. The fix is Dev Proxy: a free command-line API simulator from Microsoft that sits between your app and the real APIs and injects failures into real traffic. No code changes, no mock frameworks, no test doubles that quietly drift from reality. Your provisioning engine makes its actual Graph and SharePoint calls; the proxy just makes some of them fail. Point the Engine Through the Proxy Dev Proxy works as a regular HTTPS proxy, so the setup is a config file and an environment variable. The config says which URLs to intercept and what to do with them: { "plugins": [ { "name": "RetryAfterPlugin", "enabled": true, "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll" }, { "name": "GraphRandomErrorPlugin", "enabled": true, "pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll", "configSection": "graphRandomErrorPlugin" } ], "urlsToWatch": [ "https://graph.microsoft.com/*", "https://contoso.sharepoint.com/*", "https://contoso-admin.sharepoint.com/*" ], "graphRandomErrorPlugin": { "allowedErrors": [ 429, 500, 503 ], "rate": 50, "retryAfterInSeconds": 5 } } Then start the Functions host with its outbound traffic routed through the proxy: devproxy --config-file devproxyrc.json # terminal 1 HTTPS_PROXY=http://127.0.0.1:8000 func start # terminal 2 What’s happening here? urlsToWatch scopes the blast radius: Graph and my tenant’s SharePoint hosts. Everything else (storage, Azurite, the Durable task hub) passes through untouched - you want chaos in the APIs you’re hardening against, not in your own plumbing. GraphRandomErrorPlugin fails 50% of matching requests, randomly picking from the errors I allow. Each 429 carries a real Retry-After header, just like Graph’s. RetryAfterPlugin is the referee. It remembers every Retry-After it hands out, and if my app calls the same URL again too early, it logs a warning and throttles the request - exactly what Graph would do, except this referee tells you about it. Order matters: RetryAfterPlugin must come before the error plugin in the list, or the request is already failed before the referee sees it. On first run Dev Proxy installs a root certificate for you to trust, so it can intercept HTTPS. The Functions host picks it up from the machine store; a .NET app needs no code changes. One Storm, Three Claims Tested What I like about this setup is that a single chaotic run exercises most of the series at once. The retry ladder gets rungs pulled out. With 429/500/503 flying at 50%, every layer runs for real: the SDK handlers eat the 429s, the in-activity loops eat the semantic failures, and the orchestrator reruns whole activities when those give up. Watch the logs; every message you wrote for these paths prints for the first time. Idempotency stops being theoretical. Every activity will run twice was the claim; now they actually do. After the run completes, audit the tenant: one site, one of each list, no duplicated navigation nodes, no double-applied template artifacts. A rerun that creates a second anything is a bug you can now reproduce at will. Back-off gets policed. The one thing your own logs can’t tell you is whether you retried too eagerly. That’s the RetryAfterPlugin line to grep for: Calling https://graph.microsoft.com/v1.0/sites/... again before waiting for the Retry-After period. Request will be throttled If that shows up, some code path ignores Retry-After - in my experience usually a hand-rolled retry around a call the SDK was already retrying. Failing Deterministically Random chaos proves the transient path. The permanent path needs the opposite: a specific endpoint that always fails. That’s GenericRandomErrorPlugin with a rate of 100 and an errors file: { "errors": [ { "request": { "url": "https://contoso-admin.sharepoint.com/*" }, "responses": [ { "statusCode": 403 } ] } ] } Now every call to the admin endpoint is a 403, and the permanent-vs-transient claim is testable: the orchestration should fail in minutes with a clear error, not grind through the full retry ladder for half an hour bouncing off a wall. If you sit there watching attempt 7 of 10 against a guaranteed 403, you know which post to reread. Dial the same technique the other way: rate: 100 on a single endpoint with a 503 reproduces “step four keeps dying” as often as you need while debugging, something production will never do for you on demand. What About Just Unit Testing the Orchestrator? Fair question, and it’s where I started. I don’t, and the reason isn’t laziness. Unit testing a Durable orchestration in the isolated model means standing in for TaskOrchestrationContext, and that type is not built to be stood in for. You end up writing a mock that reimplements the runtime’s scheduling semantics, and then you’re mostly testing your mock. Every hour on that harness buys confidence about something you wrote, not about the thing Azure runs. The question I actually care about is narrower: when Graph returns 429 six times in a row, does the run still end with exactly one site? That needs the real SDK, the real retry handlers, the real replay. A mock can’t answer it, because the behaviour under test lives in precisely the parts you’d be mocking out. So the harness is the proxy, and the assertions are on the outcome rather than the call sequence. If you want unit tests here, put them on the activity bodies. Those are ordinary methods with ordinary inputs, and that’s where the logic worth unit testing lives. Gotchas Plugin order is load-bearing. RetryAfterPlugin before the error plugins, or it never sees a request and validates nothing. The docs flag this; it’s still easy to miss. Batches fail like Graph batches. When Dev Proxy fails a sub-request inside a $batch, the batch returns 424 Failed Dependency, matching real Graph behavior - so your batch response mapping gets tested too, not just single calls. A distrusted cert looks like a broken tenant. If the Dev Proxy root certificate isn’t trusted, every intercepted call dies with TLS errors and your app looks catastrophically down. If literally everything fails instantly, check the cert before your code. Scope urlsToWatch tightly. Let the proxy fail token endpoints or your storage account and you’re testing scenarios that don’t exist. Chaos belongs on the APIs you claim to be resilient against. This is failure testing, not load testing. Surviving a 50% error rate says nothing about surviving a thousand concurrent provisionings. Different question, different tools. Wrapping Up Resilience code you’ve never watched run is a hypothesis, not a feature. Dev Proxy turns “Graph had a bad day” into a repeatable local scenario: point the provisioning engine through it, inject 429s and 500s at a rate, and check that the run still lands with exactly one of everything. The series told you how the engine should behave; this is how you catch it lying. That series starts with Durable Functions: A Function That Sleeps for a Week if you want the behaviour before the test harness. --- ### Durable Functions Concurrency: Gate the Queue, Not the Host Forfatter: Jeppe Spanggaard Dato: 2026-09-10 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-09-durable-functions-concurrency-gate/ A batch of customers landed on the queue at once. Every message got picked up, every message started an orchestration, and every orchestration began building a SharePoint site. Then I watched them all crawl forward together. Site 1 finished step three. Site 2 finished step three. Site 40 finished step three. Nothing was stuck, nothing failed, and nothing finished either. The Durable runtime was faithfully round-robining between dozens of instances, one activity at a time, and the wall-clock distance from “message arrives” to “customer has a site” stretched out to something I’d be embarrassed to put in a status mail. The fix I reached for first was the obvious one. It’s also the one that doesn’t work. host.json Limits Are Not a Concurrency Limit My host.json already had this: "extensions": { "durableTask": { "maxConcurrentActivityFunctions": 3, "maxConcurrentOrchestratorFunctions": 5 } } Five orchestrators. So at most five sites at a time, right? Wrong. Those settings cap how many orchestrator and activity functions a worker holds in memory at once. An orchestration only occupies a slot while it’s actually processing an event. The moment it awaits an activity, it unloads and stops counting. Microsoft’s own performance guidance says it in one sentence: an orchestration that’s waiting for an activity to finish doesn’t count toward the throttle, and even millions of instances in Running state can sit there while only the ones in memory count. My orchestrations spend nearly all their time awaiting activities that call SharePoint. So the effective limit on simultaneous site builds was: however many messages arrive. The throttle throttles episodes, not work. The second half of the same doc page is worth having tattooed somewhere: concurrency throttles apply per worker and don’t limit total system throughput. Even if the numbers did what I thought, they’d only do it one instance at a time. The Throttling You Don’t See Here’s the part that made this more than an impatience problem. Every one of those parallel site builds hits SharePoint as the same app registration, and SharePoint’s application limits are counted per app per tenant across Graph, CSOM and REST together. Forty sites building at once is forty times the request rate against one bucket. And I never saw a single 429. Not one. CSOM’s retry helpers and the Graph client’s retry handler both catch throttling responses, honour Retry-After, sleep, and try again. From the outside, that isn’t an error, it’s just slow. What I actually observed was retries climbing and durations stretching, which reads like “SharePoint is having a bad day” rather than “I am the bad day”. That’s the trap: the SDKs are good enough at absorbing throttling that you can be deep into it without a single failed request to point at. Microsoft’s own first line of advice for avoiding it is “reduce the number of concurrent requests”, which is exactly the knob I thought host.json was giving me. The Durable Functions Concurrency Gate So the limit has to live where the work is started, not where it runs. The Service Bus subscriber asks a gate for permission before scheduling anything: internal async Task HasCapacityAsync(DurableTaskClient client, CancellationToken cancellationToken = default) { var query = new OrchestrationQuery { Statuses = [OrchestrationRuntimeStatus.Running, OrchestrationRuntimeStatus.Pending], InstanceIdPrefix = InstanceIdPrefix, CreatedFrom = DateTimeOffset.UtcNow - LookbackWindow, FetchInputsAndOutputs = false }; var cap = _settings.MaxConcurrentOrchestrations; var active = 0; // Stop counting at the cap; never enumerate the full instance history. await foreach (var _ in client.GetAllInstancesAsync(query).WithCancellation(cancellationToken)) { if (++active >= cap) { logger.LogInformation("Provisioning at capacity ({Active}/{Cap} orchestrations active).", active, cap); return false; } } return true; } What’s happening here? The query counts instances that are actually occupying the system: Running and Pending. Completed, failed and terminated instances are irrelevant to capacity. InstanceIdPrefix is why the deterministic instance IDs from the dedupe design pay off twice. Because every provisioning instance is named provision-customer-{id}, the prefix is a free filter that separates provisioning from every other orchestration in the task hub. CreatedFrom bounds the scan to the last seven days. The Azure Storage backend doesn’t index instances by ID prefix, so without a time bound the count walks more history every week the app lives. The loop returns the moment it reaches the cap. It’s a “do we have room” question, not a “how many are there” question, and the cheap version of that answer is the one that stops counting early. FetchInputsAndOutputs = false keeps the payloads out of it. Nothing here needs to know what the orchestrations are doing. My cap is 2, in config rather than in code, and two sites in parallel finish in about three minutes. That’s the number that made the difference: two runs that complete beat forty that all progress. Deferral Is Not Abandonment Being at capacity doesn’t mean rejecting the message. It means putting it back with a delay: private static readonly TimeSpan[] DeferDelays = [ TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(10) ]; // Publish-then-complete: caller must schedule the clone BEFORE completing the original. // A crash between the two leaves a harmless duplicate rather than a lost message. internal async Task DeferAsync(ServiceBusReceivedMessage original, int deferCount, CancellationToken cancellationToken = default) { var delay = DeferDelays[Math.Min(deferCount, DeferDelays.Length - 1)]; var jitter = TimeSpan.FromSeconds(Random.Shared.Next(_settings.DeferJitterSeconds + 1)); var scheduledTime = DateTimeOffset.UtcNow.Add(delay).Add(jitter); var clone = new ServiceBusMessage(original.Body) { ContentType = original.ContentType, Subject = original.Subject, ScheduledEnqueueTime = scheduledTime }; clone.ApplicationProperties[DeferCountProperty] = deferCount + 1; clone.ApplicationProperties[FirstSeenUtcProperty] = GetFirstSeenUtc(original).ToString("o"); await _sender.ScheduleMessageAsync(clone, scheduledTime, cancellationToken); } Why a scheduled clone instead of abandoning the message? Because abandoning gives you Service Bus’s redelivery schedule, which is seconds, and counts toward MaxDeliveryCount. A message that waits an hour for a slot would dead-letter itself on the broker’s timetable rather than mine. Scheduling a fresh copy with ScheduledEnqueueTime means the wait is mine to define, and the delivery count never moves. The delays escalate 1, 2, 5, 10 minutes and then hold at 10, with up to 30 seconds of jitter on top. The jitter is not decoration: without it, every message deferred in the same second wakes in the same second and stampedes the gate together, which is the same burst I was trying to prevent, just on a timer. Two application properties ride along. DeferCount picks the next delay. FirstSeenUtc is copied across every bounce so the log line can say how long this customer has genuinely been waiting, not how long since the last hop. And the ordering is deliberate: schedule the clone, then complete the original. Crash in between and you get a duplicate message, which the dedupe checks make a no-op. Do it the other way and a crash loses a customer’s site request entirely. Gotchas The cap is soft. The instances query is eventually consistent, so two workers can both pass the gate at the same moment. Occasional N+1 is fine and expected. If you need an exact count, a Durable entity acting as a semaphore is the tool, and it costs you a lot more moving parts. Gate last, after the dedupe checks. A redelivered message for a customer that already has a site should short-circuit, not queue up for a slot it will never need. There is no deadline, on purpose. A message with no free slot waits indefinitely. Any age limit would eventually dead-letter customers whose only crime was arriving late in a backlog, and a large bulk run legitimately takes days at a cap of two. A wedged instance eats a slot forever. One orchestration stuck in Running permanently reduces the cap, and with no deadline the queue behind it just waits. Alert on deferral volume and on the oldest FirstSeenUtc, because nothing else will surface it - the same log search alert that catches failed orchestrations is where those queries belong. FIFO is gone. A deferred message is completed and republished, so it gets a new sequence number and loses its place in line. Harmless when customers are independent of each other. Not harmless if something downstream assumes order. Wrapping Up maxConcurrentOrchestratorFunctions sounds like a concurrency cap and isn’t one: it limits orchestrator episodes in memory per worker, and an orchestration waiting on an activity doesn’t count. If you need “only N of these at a time”, the only place to enforce it is before the orchestration is scheduled. Count the non-terminal instances by prefix, and when you’re full, hand the message back to the queue with a future delivery time and some jitter. Rule of thumb: throttle where work is admitted, not where it runs. --- ### The Site Exists, Graph Says 404: Eventual Consistency in Provisioning Forfatter: Jeppe Spanggaard Dato: 2026-09-05 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-09-sharepoint-graph-eventual-consistency-404/ My provisioning engine had just created a SharePoint site. I could open it in the browser. Title rendered, default library in place, everything fine. The very next line of code asked Microsoft Graph for that site’s ID. Not found. I created you two seconds ago. I’m looking at you. And Graph, with a straight face: no such site. Welcome to eventual consistency between SharePoint and Graph. The site creation goes through CSOM against SharePoint directly; the ID lookup goes through Graph, which has its own view of the world that updates on its own schedule. For a freshly created site, that schedule can lag from seconds to a couple of minutes. Your code sits exactly in that gap. The Loop This runs at the end of my site creation activity, after the site exists in SharePoint: private async Task ResolveGraphSiteIdWithRetryAsync(string siteUrl) { var uri = new Uri(siteUrl); var serverRelativePath = uri.AbsolutePath.TrimStart('/'); var siteIdentifier = $"{uri.Host}:/{serverRelativePath}"; const int maxAttempts = 8; var delay = TimeSpan.FromSeconds(10); for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { var site = await graphClient.Sites[siteIdentifier].GetAsync(); return site?.Id ?? throw new InvalidOperationException($"Graph returned null site ID for {siteUrl}"); } catch (ODataError ex) when (ex.ResponseStatusCode == 404 && attempt < maxAttempts) { // Site not yet indexed in Graph - wait and retry logger.LogInformation( "Graph site not found yet (attempt {Attempt}/{Max}), retrying in {Delay}s...", attempt, maxAttempts, delay.TotalSeconds); await Task.Delay(delay); delay = TimeSpan.FromSeconds(Math.Min(delay.TotalSeconds * 1.5, 60)); } } throw new InvalidOperationException($"Could not resolve Graph site ID for {siteUrl} after {maxAttempts} attempts."); } What’s happening here? The site is addressed as {hostname}:/sites/{alias} - Graph’s path-based site addressing, so no ID is needed to ask for the ID. The thing it hands back is the composite {hostname},{siteCollectionId},{webId} form, three parts comma-joined, which is why you can’t just build one yourself: two of the three are GUIDs with no relationship to the URL. The catch filter is deliberately narrow: ODataError and status 404 and attempts remaining. A 403 means permissions and should fail now. A 401 means auth and should fail now. Only “not found” means “not found yet”. Backoff starts at 10 seconds and multiplies by 1.5 with a 60-second cap: 10, 15, 22, 34, 51, 60, 60. Eight attempts covers about four minutes, which in practice covers the Graph indexing lag with room to spare. When the loop gives up, it throws an ordinary exception, and something interesting happens one level up. More on that next. Three Layers of Retry, Three Different Jobs This loop lives inside a Durable Functions activity that already has retries around and below it. It took me a while to see these as separate tools rather than redundant paranoia, so here’s the layer map: Layer 1: the HTTP pipeline. The Graph SDK’s Kiota handlers honor Retry-After on 429s; my CSOM calls go through PnP’s ExecuteQueryRetry, which does the same for SharePoint throttling. This layer retries individual requests over seconds. It handles “you’re going too fast”. Layer 2: in-activity loops like the one above. This layer knows the semantics of the operation. No HTTP handler can know that a 404 right after site creation means “wait”, while a 404 anywhere else means “broken”. Only code that knows a create just happened can decide that. Seconds to minutes, single operation. Layer 3: the orchestrator’s retry policy. Every activity call gets 10 attempts with exponential backoff from 5 seconds to 5 minutes. This layer retries whole activities, which is why the loop’s final throw is a plain InvalidOperationException: if four minutes of polite waiting wasn’t enough, the orchestrator reruns the entire activity. The rerun finds the site already exists, skips creation, and effectively buys the resolution another eight attempts, minutes later. The layers compose. The failure mode to avoid is collapsing these into one: cranking the orchestrator policy to 30 attempts because “Graph is flaky”. Then every genuine failure, including permanent config errors, grinds through half an hour of retries, and you still haven’t expressed the one thing you actually knew - that a 404 after a create is expected. Each layer catches what it can name. The HTTP layer names status codes. The activity names operations. The orchestrator names steps. Naming Things: Reading a Graph SDK Exception All of that depends on the middle layer being able to tell a 404 from a 403, so it’s worth spelling out where ex.ResponseStatusCode in that catch filter comes from. Most of the advice you’ll find online is written against a type that no longer exists: SDK v5 replaced ServiceException with ODataError. catch (ODataError ex) { var status = ex.ResponseStatusCode; // 404, 403, 401... var code = ex.Error?.Code; // "itemNotFound", "accessDenied" var msg = ex.Error?.Message; var inner = ex.Error?.InnerError; // request-id and date, for support tickets } Status code for control flow, Error?.Code when two failures share a status and you need to tell them apart, and InnerError for the request ID that support will ask for. It’s Not Just Site IDs The same lag shows up one step later in my pipeline, wearing a different costume. After the site exists, an activity pulls content types from the content type hub. On a fresh site, that pull can fail with a transient HTTP 400, or report success while the content types take their time actually appearing - and a missing content type breaks the PnP template apply that comes after, because child content types have no parent to inherit from. The pattern there is verify-then-escalate: after requesting the pull, the activity polls for each content type’s presence with a short bounded backoff. Genuinely present: done. Still missing after the window: throw a transient error so the orchestrator retries the whole pull, since a content type that never got pulled won’t appear no matter how long you stare at it. There’s a 503-shaped sibling worth recognising too: Microsoft.Graph.Models.ODataErrors.ODataError: The underlying service is experiencing a high load and is unable to fulfill your request. Please retry after a brief backoff period. Same treatment as the 404: transient, retry it. The catch is that this one asks you to back off without telling you how long - there’s no Retry-After header to honour, so the Kiota retry handlers have nothing to work with and the delay has to come from your own code. Layer 2’s job again. Same philosophy as the 404 loop, different escalation point. Wait briefly for the thing that’s probably coming; retry the operation that produces it if it demonstrably isn’t. What you never get to do is assume that “the API returned success” and “the resource is usable” happen at the same time. In Microsoft 365 provisioning, they’re separated by an index, a cache, or a queue you can’t see. Gotchas Bound every wait. Every retry loop in the engine has a max attempt count and a delay cap, and the activity level has the orchestrator’s policy above it as a final bound. An unbounded “wait until it appears” loop is an outage with extra steps. Task.Delay is fine here, in an activity. In an orchestrator function it would be a determinism violation - there you’d use context.CreateTimer. Activities are plain code; orchestrators replay. Know which kind of function you’re standing in before you sleep. Log the attempt number and the delay. “Graph site not found yet (attempt 3/8), retrying in 22s” in the logs turns a scary multi-minute silence into a visible, healthy process. My first version logged nothing and I spent an evening convinced the function was hung. Don’t cache your way around it. My first instinct was to compute the Graph site ID from the URL format and skip the lookup. Tempting, but the composite ID contains GUIDs you can’t derive, and half-guessed IDs fail later in worse places. Wait for the real one. The lag varies wildly. Dev tenant: usually under 15 seconds. Production tenant under load: I’ve seen over two minutes. Tune maxAttempts for the worst tenant you serve, not the one on your laptop. Wrapping Up A 404 immediately after a create isn’t an error, it’s a timestamp - proof you’re faster than the index. Catch exactly that case in a narrow, bounded, backing-off loop inside the activity, and leave throttling to the HTTP layer and rerun-the-step to the orchestrator. Retry layers work when each one handles only what it can name. The “rerun the step” half of that sentence is the orchestrator’s job, and Durable Functions: A Function That Sleeps for a Week explains why it can be trusted with it. --- ### Failed Durable Functions Runs: One KQL Alert, No Code Forfatter: Jeppe Spanggaard Dato: 2026-08-30 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-durable-functions-failed-orchestration-alert/ I’m in the middle of a bulk provisioning run: a queue message per new customer, one Durable Functions orchestration per site, more than 10,000 sites to create. Three of them have failed. I know that because I’ve been opening the portal several times a day and looking. There’s a customer deadline on this run, and if the failures turn out to be a pattern rather than three unlucky sites, I want to stop the whole thing before it creates 9,000 more. So I check. Manually. Like a night watchman without a bell. Three failures out of ten thousand is a rounding error until it’s the three sites belonging to the customer who calls. A Failed Orchestration Tells Nobody Here’s the thing that makes this harder than it sounds: nothing surfaces a failed orchestration on its own. My orchestrator wraps the whole run so a failure gets written to the status list before it propagates: private async Task ExecuteProvisioning(Func> action) { try { return await action(); } catch (Exception ex) { _logger.LogError(ex, "Provisioning failed."); // Try to update status, but never let a status-update failure mask the original error. try { await TryUpdateStatus(errorMessage: ex.Message); } catch (Exception statusEx) { _logger.LogWarning(statusEx, "Failed to update status after provisioning error."); } throw; } } That bare throw; is the load-bearing line. Because the exception isn’t swallowed, the instance ends in runtime status Failed instead of quietly finishing as Completed with half a site behind it. Good. But Failed is a row in a storage table and a red badge in a tool nobody has open at 21:00. And getting there takes a while. Every activity call runs under a retry policy of 10 attempts with exponential backoff from 5 seconds up to 5 minutes, so an activity that can’t succeed spends roughly 25 minutes failing before the orchestration is allowed to give up. The hub association step is the reigning champion: JoinHubSite can return 200 and silently do nothing on a freshly created site, so the activity reads the association back and throws a plain InvalidOperationException when it didn’t stick. Plain, on purpose, because that one usually is transient. It gets all ten attempts before the orchestration dies. If you want the other half of that story, permanent versus transient failures is about which exceptions deserve the full ten. So the failure is real, it’s slow, and it’s silent. I needed the bell. Three Ways to Get Told, and Why I Picked the Boring One I had three options on the table. Publish an Event Grid event when the orchestration fails. I already run Event Grid in this solution, so the plumbing exists. But it means code in the failure path, a new event type, a subscriber, and a deployment. And every time I want the notification to say something slightly different, that’s another deployment. A timer-triggered watchdog. A function that wakes up every N minutes, queries the instance table for anything in Failed, and shouts. Also code, also a deployment, plus its own state to remember what it already shouted about. Now I have two things to monitor: the pipeline and the thing watching the pipeline. A log search alert in Application Insights. No code at all. The Durable Functions extension already writes lifecycle events for every orchestration, and I already have an action group wired up for this customer’s other alerts. Write a KQL query, point an alert rule at it, done. I picked the third one, and the deciding argument wasn’t elegance. It was that I bill by the hour. The alert costs about $1.50 a month; writing and deploying either of the other two costs more than that in the first fifteen minutes, and then keeps costing every time it needs a tweak. The KQL, by contrast, I can widen tomorrow, from the portal, without touching the function app. The two code options are better if you need the failure to do something automatically. Mine only needs to reach a human. Durable Functions Already Logs Every Failure The reason no code is needed: the Durable extension emits tracking events to Application Insights for every lifecycle transition, under the log category Host.Triggers.DurableTask. They land in the traces table, and everything useful is hiding in customDimensions behind a prop__ prefix. This is the query behind the alert: traces | where customDimensions.Category == "Host.Triggers.DurableTask" | extend functionName = tostring(customDimensions["prop__functionName"]) | extend functionType = tostring(customDimensions["prop__functionType"]) | extend instanceId = tostring(customDimensions["prop__instanceId"]) | extend state = tostring(customDimensions["prop__state"]) | extend reason = tostring(customDimensions["prop__reason"]) | extend isReplay = tobool(tolower(customDimensions["prop__isReplay"])) | where isReplay != true | where functionType == "Orchestrator" and state == "Failed" | project timestamp, functionName, instanceId, reason, appName = cloud_RoleName What’s happening here? The category filter is the whole trick. Host.Triggers.DurableTask is the extension’s own channel, separate from anything my code logs, so the query keeps working no matter how I change my own logging. The extend block lifts the tracking fields out of the dynamic customDimensions bag and casts them to strings. Without tostring, comparisons against dynamic values get weird in ways that cost you an afternoon. isReplay != true drops replayed history. Orchestrators replay their execution constantly, so without this filter a single failure can produce the same tracking event more than once, and your alert email count stops meaning anything. I wrote it in defensively from the start rather than learning it the fun way. functionType == "Orchestrator" is the difference between “a run failed” and “an attempt failed”. Activities emit Failed events too, and with a 10-attempt retry policy a perfectly healthy orchestration produces a pile of them on the way to succeeding. Filter to the orchestrator and you get one row per genuinely dead run. reason carries the error details, so the alert email arrives with the instance ID and the actual message. That’s the difference between “go look” and “here’s what broke”. The Alert Rule, Setting by Setting Nothing exotic, and that’s the point: Signal name: Custom log search Query type: Aggregated logs Measure: Table rows, Aggregation type: Count, Aggregation granularity: 5 minutes Threshold type: Static, Operator: Greater than, Threshold value: 0 Frequency of evaluation: 5 minutes Static threshold, not dynamic. Dynamic thresholds learn a baseline and alert on deviation, which is the right tool for something with a natural rhythm. The natural rhythm of failed provisioning runs is zero. Any row is an incident, not an anomaly. Threshold 0 on row count with a 5-minute window means “tell me about the first one”, which is exactly what I want during a bulk run. Three failures out of ten thousand is fine. Three failures in ten minutes means the hub is down and I should stop the queue. The action group sends mail to me and to the customer’s IT department. That second recipient changed how I write the alert description: it has to make sense to someone who has never seen a task hub. The description says what failed, where the instance ID can be looked up, and who does what next. Why Would the Alert Miss a Failure? Sampling. Application Insights samples telemetry under load, and traces is sampled by default. A high-volume run producing thousands of lifecycle events per minute is exactly the situation where sampling starts dropping rows - including, possibly, the one you’re alerting on. This is also the answer to the more general complaint that Azure Functions invocation logs go missing sometimes, or that App Insights alerts miss exceptions that definitely happened. The telemetry isn’t lost because something broke; it was sampled out, and sampling gets more aggressive precisely when things are busiest. Which is when you need it. If your run is that noisy, look at samplingSettings in host.json before you trust the alert. Raising maxTelemetryItemsPerSecond or excluding types from sampling is a one-line change, and the Azure Functions monitoring docs spell out the trade-off. The uncomfortable part is that an alert built on sampled data fails silently: you don’t get a wrong answer, you get no answer, which looks identical to nothing having gone wrong. Gotchas Failed is not the only bad ending. An orchestration stuck in Running never emits a Failed event, so this alert says nothing about it. With retries nested inside HTTP-level retries, a single activity can hold a run open for a long time and look perfectly healthy. If that’s a risk for you, a second alert on orchestration duration is the companion piece. Alert on the orchestrator, not the activity. I cannot stress this one enough. Drop the functionType filter and your retry policy becomes a mailing list. Every attempt under CallActivityWithRetryAsync (or CallActivityAsync with retry options) logs its own Failed event, so one healthy orchestration that recovers on attempt four has already emitted three of them. isReplay is not optional. Replay is normal behaviour, not a fault, and the same lifecycle event can show up more than once without that filter. A threshold of zero only works if failures are rare. Mine are. If yours are routine, raise the threshold and treat the alert as a rate detector instead, or you’ll train yourself to archive the mail unread. The alert is detection, not remediation. Getting the mail is step one. Knowing what to do with a Failed instance, and whether a rerun is safe, is a separate problem that idempotent activities exist to make answerable. Wrapping Up A failed Durable Functions orchestration is loud in the storage table and silent everywhere a human looks. The Durable extension already logs every lifecycle transition to Application Insights, so the cheapest possible monitor is a KQL query over traces filtered to non-replay orchestrator events in state Failed, wired to an action group you already own. Rule of thumb: before you write a watchdog, check whether the thing you want to watch is already logging it. Mine was, and the difference between knowing and not knowing cost me $1.50 a month. New to this corner of Azure? Durable Functions: A Function That Sleeps for a Week is where the rest of this series starts. --- ### Stop Retrying the Unretryable: Permanent Failures in Durable Functions Forfatter: Jeppe Spanggaard Dato: 2026-08-25 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-durable-functions-permanent-vs-transient-failures/ I watched my provisioning orchestration retry an activity ten times over roughly 25 minutes. The error? An invalid site type string in the provisioning schema. A config mistake. Attempt one failed with “unknown site type”. So did attempt two, five seconds later. And attempt three, ten seconds after that. Exponential backoff dutifully stretched the intervals to five minutes while the outcome stayed exactly as impossible as it was at second zero. Retrying a throttled request is smart. Retrying a typo is just failing slowly. Retry Policies Don’t Read Exceptions My orchestrator calls every activity with the same retry options: 10 attempts, exponential backoff from 5 seconds up to 5 minutes. That’s the right shape for what the policy is there for - SharePoint throttling, Graph hiccups, the occasional 503. Transient stuff. But RetryPolicy is blunt. Timeout? Retry. HTTP 429? Retry. “The site type ‘TeamSiet’ does not exist”? Retry, obviously, maybe the spelling will heal. The fix has two halves: activities that know when they’ve hit a wall, and an orchestrator that listens. Half One: Activities Declare Permanence /// /// Thrown by activities to signal a non-transient failure that should NOT be retried. /// Examples: invalid configuration, illegal site name, missing required fields. /// The orchestrator inspects this to short-circuit the retry policy. /// public sealed class PermanentProvisioningException : Exception Activities throw it exactly where a retry can’t help: an unknown site type in the schema, a hub site ID that isn’t a valid GUID, a PnP template file that doesn’t exist at the configured path. These are all “fix the config and start over” failures. No amount of waiting turns a missing file into a present one. Everything else - network faults, throttling, SharePoint having a moment - throws ordinary exceptions and rides the retry policy as designed. Half Two: The Orchestrator Listens Every activity call goes through a wrapper instead of calling CallActivityAsync raw: /// /// Calls an activity with retry options, but short-circuits immediately for permanent failures. /// Durable Functions' built-in RetryPolicy retries ALL exceptions; this wrapper inspects the /// inner exception and re-throws immediately if it's a PermanentProvisioningException. /// private async Task CallActivitySafeAsync(string activityName, object input) { try { await context.CallActivityAsync(activityName, input, ActivityRetryOptions); } catch (TaskFailedException ex) when (IsPermanentFailure(ex)) { throw new PermanentProvisioningException( $"Activity '{activityName}' failed with a non-retryable error: {ex.InnerException?.Message ?? ex.Message}", ex); } } And the detection, which is where the real lesson lives: private static bool IsPermanentFailure(TaskFailedException ex) { // Check if the activity explicitly signaled a permanent failure if (ex.InnerException is PermanentProvisioningException) return true; // Also check the failure details for the exception type name, since // Durable Functions may serialize/deserialize across process boundaries. var details = ex.FailureDetails; if (details?.ErrorType?.Contains(nameof(PermanentProvisioningException), StringComparison.Ordinal) == true) return true; return false; } What’s happening here? Activity failures surface in the orchestrator as TaskFailedException, with the original error wrapped inside. The first check is the obvious one: is the inner exception our permanent type? The second check is the one you’d never write until it bites you. In the isolated worker model, the activity’s exception is serialized when it crosses from the worker process back to the host and into orchestration history. What you get back is not necessarily your exception instance - sometimes all that survives is FailureDetails with the type name as a string. So the check falls back to matching ErrorType textually. If you see Failed to deserialize exception from TaskActivity in the logs, that’s the same boundary telling you out loud what it usually does quietly. In the in-process model the shape is the same with different names: the wrapper is FunctionFailedException, and its InnerException arrives as a plain System.Exception rather than your type, which is why catch (MyException) never fires there either. When either check hits, the wrapper rethrows as a fresh PermanentProvisioningException and the orchestration fails right now, with a message naming the activity, instead of 25 minutes from now. If you only take one thing from this post: an exception that crosses the Durable Functions process boundary is not guaranteed to arrive as its original type. Any design that relies on catch (MySpecificException) around CallActivityAsync needs the string-based fallback, or it will silently degrade into retry-everything. Wait, doesn’t RetryPolicy have a handle-callback for this? The in-process model had RetryOptions.Handle; in the isolated model the equivalent is a retry handler reading RetryContext.LastFailure, where IsCausedBy() does the type test for you. That shape has been shakier across versions, and even where the callback exists you’re inspecting the same serialized FailureDetails anyway. I went with the explicit wrapper: it’s boring, it’s visible in the call path, and it works the same on every version I’ve touched. Worth naming the in-process spelling too, since most samples you’ll find use it: CallActivityWithRetryAsync is the same call as CallActivityAsync(name, input, retryOptions) here. If your retry policy appears to do nothing at all, this section is usually why - the policy is running fine, your catch just never matches, so every attempt burns. The Inverse Trick: Throwing to Request a Retry Once permanent and transient failures are distinct, activities can use exceptions the other way around: throw a deliberately transient exception to ask for a rerun. My property bag activity writes values to a fresh site, then reads them back, because SharePoint occasionally accepts the write and forgets it. When verification fails: throw new InvalidOperationException( $"Property bag verification failed on {siteUrl}. " + "Values did not persist to the server; retrying the activity."); Plain InvalidOperationException, on purpose. It doesn’t match the permanent check, so the retry policy picks it up and reruns the activity, which rewrites and reverifies. The exception type has become a control channel: permanent means “stop, a human needs to fix input”, everything else means “run me again”. That’s the whole taxonomy. Two kinds of failure, one bit of information, and the activity is the only place with enough context to set that bit. The orchestrator can’t know whether a 500 from SharePoint is a fluke or a doomed request - but the activity that built the request usually can. Gotchas Fail fast still means fail loudly. When the wrapper short-circuits, my orchestrator writes the error message to the provisioning status list before rethrowing, so the person who typo’d the schema sees “unknown site type” next to the customer row instead of finding a generic failed instance in the portal a day later. Be stingy with permanence. Marking a failure permanent when it’s actually transient is worse than the reverse: the reverse wastes 25 minutes, but a wrong permanent verdict kills runs that would have succeeded. When in doubt, let it retry. ErrorType matching should use the exception’s name, via nameof. Hardcoding the string invites drift when the class gets renamed; nameof(PermanentProvisioningException) keeps the match and the type in lockstep. maxNumberOfAttempts counts the first call. Ten attempts means the original plus nine retries, not ten retries. Set it to 1 and you have disabled retries entirely - which is a perfectly good way to convince yourself the final retry “never executes”. Don’t forget the sub-orchestration case. If you split into sub-orchestrations later, the same wrapping applies to CallSubOrchestratorAsync - permanent failures need to propagate up through every layer that has a retry policy, or one layer’s policy will happily retry another layer’s typo. Wrapping Up A retry policy without a permanent-failure escape hatch turns config errors into slow-motion failures. Give activities an exception type that means “never retry this”, short-circuit it in the orchestrator, and match on the serialized type name, not just the .NET type, because the process boundary eats exception identity. Retries are for the weather, not for typos. This one assumes you already have a retry policy worth escaping. Durable Functions: A Function That Sleeps for a Week is the starting point for the rest of this series. --- ### AllowIncrementalResults: CAML Past the 5,000 Item Wall Forfatter: Jeppe Spanggaard Dato: 2026-08-20 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-caml-allowincrementalresults-list-view-threshold/ Nothing about the query changed. The list just got bigger. That’s the list view threshold, and it’s the reason code that works fine in dev falls over in production two years later. AllowIncrementalResults is the CSOM property that gets you through it. Why a Ten-Row Query Fails on an 8,000 Item List The error looks like this: Microsoft.SharePoint.Client.ServerException: The attempted operation is prohibited because it exceeds the list view threshold. CSOM hands you that as a ServerException, but the thing throwing it server-side is SPQueryThrottledException. Worth knowing both names: the message is what you get in the debugger, the class name is what half the search results are filed under. SharePoint REST hits the same wall for the same reason, though not with the same fix: AllowIncrementalResults is a CamlQuery property and there is no REST equivalent of it. What throws people is that your filter has nothing to do with it. You can ask for ten rows out of 8,000 and still get the exception, because SharePoint is protecting itself against the scan, not against the result set. Set the Flag, Then Page var query = new CamlQuery { ViewXml = viewXml, // 5000, indexed field in AllowIncrementalResults = true, // the part that gets you past the threshold }; ListItemCollection items; do { items = list.GetItems(query); context.Load(items); await context.ExecuteQueryAsync(); Process(items); query.ListItemCollectionPosition = items.ListItemCollectionPosition; } while (query.ListItemCollectionPosition is not null); What’s happening here? AllowIncrementalResults = true tells SharePoint it’s allowed to hand back what it has, in chunks, instead of refusing the whole query. Without it, a big list plus a filter is a ServerException and nothing else. in the view XML decides the chunk size. It’s the same knob you’d use for ordinary paging, and it has to be there - an unbounded query is exactly what the threshold exists to stop. I use 5000. ListItemCollectionPosition is the cursor. Feed it back into the next query, stop when the collection comes back with a null position. That’s the last page. Every iteration is one round trip, so the chunk size is a real trade-off: bigger pages mean fewer trips and heavier payloads. I count round trips out of habit these days, for reasons I’ve written about at length. Indexed Columns Are Not Optional This is the part that turns “it doesn’t work” into an afternoon. The flag isn’t a bypass. The fields you filter and sort on still have to be indexed on the list, or you’re back to the same exception with a more confusing story, because now you did set the flag. One exception, and it’s a useful one: a field you’ve projected across a isn’t a column on the list at all, so it can’t be indexed, and the flag carries it anyway. I only found that out by measuring it on a 10,000 item list, where a join filtered on a projected field is refused flat without the flag and returns every matching row with it. So the rule is: real columns need an index, projections need the flag. Add the index in list settings, and remember SharePoint allows up to 20 per list. And if your first thought is “the list is already too big to index” - that’s the reflex I had too, and it’s wrong. Microsoft’s add an index to a list or library column walks through it and says you can index a list of any size manually. If it does get blocked, do it inside the tenant’s daily time window. Gotchas The flag doesn’t raise the threshold. It changes the failure into incremental delivery. Unindexed and columns still fail, with the projected-field exception above. No , no paging. Without it you’re asking for everything again, and the position never comes back. If you’re here because RowLimit “isn’t working”, check where you put it: it belongs in the view XML outside , and on its own it caps one response rather than giving you the rest of the rows. Paging is what gives you the rest. You cannot parallelise the paging. Each page’s cursor comes out of the previous response, so the loop is strictly sequential no matter how many threads you throw at it. Chunk size is your only real lever on total time. Calculated columns can’t be indexed. Neither can a few other types. Discovering that late is how a filter design dies, because the column your whole query hangs on turns out to be the one column SharePoint won’t index - and no amount of AllowIncrementalResults saves it. ListItemCollectionPosition belongs to the query, not the list. Set it on the CamlQuery you’re about to execute. Reusing a stale position from an earlier query shape gives you nonsense. The ceiling moves per tenant and per time of day. Large list throttling is a service-side feature, so “it worked yesterday” is not evidence that it will work in the batch job tonight. The other APIs hit the same wall, and only one of them is polite about it. I used to say I’d never got this to work outside CSOM, so I went and measured all three. REST answers an unindexed filter with a bare 500. Graph answers 400 and names the header that would let it through. Neither has an equivalent of this flag, so filtering a big list still ends up back in CSOM, which is one more entry on the pick-one list. Folders change the picture. Scoping a query to a folder cuts the item count SharePoint has to consider, which sometimes solves the problem without any of this. Wrapping Up If a CAML query is throwing the list view threshold exception, the fix is three things together: AllowIncrementalResults = true, a to page with, and indexes on the columns you filter and sort on. Two out of three still throws. It’s also one of the reasons list item work stays in CSOM for me. Picking the right SharePoint API per operation means knowing which one has an answer for the awkward cases, and large lists are as awkward as it gets. The wider version of that argument is in the two benchmark posts: what a CAML join costs at 1,000 items, and what survives at 10,000. The short version is that past the threshold this stops being a speed question. It becomes a question of whether you get the right answer at all, and of how much of the list you have to hold in memory to find it. --- ### List View Threshold: What Still Works at 10,000 Items Forfatter: Jeppe Spanggaard Dato: 2026-08-16 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-list-view-threshold-what-still-works/ I’ve lost count of how many times I’ve hit this. A list quietly grows past 200,000 items, somebody asks for a filtered view of it, and the code that has worked for two years answers with this instead: Microsoft.SharePoint.Client.ServerException: The attempted operation is prohibited because it exceeds the list view threshold. The code didn’t change. It never does. CSOM has an escape hatch for this, AllowIncrementalResults, and it gets a post of its own. What I could never answer was the obvious follow-up. Is CSOM the only place with an answer? Every time I’d needed to filter past the threshold I’d ended up back in CSOM, but I’d never actually gone and checked what REST and Graph do. Saying “I don’t know” for two years is a long time. So I took the harness from the CAML joins benchmark, seeded a second four-list chain at 10,000 items per list, and ran everything again. Ten thousand is not two hundred thousand, but it’s twice the threshold, which is where the behaviour changes. What actually breaks at the SharePoint list view threshold? Not reading. I can still pull all 10,000 rows out of any of these APIs, and it costs two requests. What breaks is filtering: the moment a or a $filter touches a column SharePoint hasn’t indexed, the query is refused, and it’s refused even when the answer would be ten rows. The threshold is about the scan, not the result set. That single sentence explains almost everything below. Reading Was Never the Problem The first thing I measured is the thing nobody worries about, and it turns out to be fine: Reading all 10,000 rows Requests CAML join over CSOM, RowLimit paging 2 SharePoint REST, $top=5000 plus odata.nextLink 2 Graph, $top=5000 plus @odata.nextLink 2 Two requests each, because 10,000 rows is two pages of 5,000. An unfiltered read past the threshold is allowed on all three. If your job is “give me the whole list”, the threshold barely exists. That’s worth saying plainly because the error message sends people hunting for the wrong fix. You don’t need to restructure your list to read it. The Flag That Shouldn’t Work, and Does Here’s the one I got wrong, and I was confident about it. The most useful thing a CAML join does is let you filter on a column that lives several lists away, by projecting it and putting the on the projection. But a projected field is not a column on the list. It doesn’t exist there, so it cannot be indexed. And the documented rule for AllowIncrementalResults is that the fields you filter and sort on still have to be indexed. I was sure that made the technique impossible past 5,000 items. I wrote the probe expecting to have to publish a correction. var query = new CamlQuery { ViewXml = viewXml, // , , on customerNo AllowIncrementalResults = true, }; var all = new List(); do { var items = list.GetItems(query); context.Load(items); context.Load(items, i => i.ListItemCollectionPosition); context.ExecuteQuery(); all.AddRange(items); query.ListItemCollectionPosition = items.ListItemCollectionPosition; } while (query.ListItemCollectionPosition is not null); What’s happening here? Without AllowIncrementalResults, this exact query is refused: SPQueryThrottledException, straight away, no rows. With it, the same query returns all 5,000 matching rows in two requests. The filter is on customerNo, which is projected from a list two hops away and cannot be indexed anywhere. ListItemCollectionPosition is the cursor and it is not optional. A on its own doesn’t page, it truncates, and truncation is the failure mode you don’t notice. You need both properties loaded. context.Load(items) alone won’t populate the position on every code path, and a null cursor looks exactly like a finished result set. So the join survives the threshold. My best guess at why is that the projection is resolved after the join rather than as a scan predicate, but that’s inference, not something I can prove from the outside. What I can say is that it returned the right 5,000 rows, ten times in a row. Then I checked whether the depth matters, because it easily could have. Everything above filters on a value two lists away, which is the shape I’ve written about before. So I moved the query one list further back and made the projection travel three joins instead of two: Projection travels Without the flag With the flag Two joins SPQueryThrottledException 5,000 rows, 2 requests Three joins SPQueryThrottledException 5,000 rows, 2 requests Identical, right down to the request count. Whatever the flag is doing, it isn’t running out of road at the second hop, and a filter three lists from the one you’re querying is as viable past the threshold as a filter one list away. Three APIs, Three Different Refusals Filter on a column that genuinely isn’t indexed, and all three refuse. How they refuse is where they differ. API Status What you get CSOM exception SPQueryThrottledException SharePoint REST 500 the same exception, wrapped in an OData error body Graph 400 a message that names the fix Graph wins this one, and it isn’t close: Field 'Title' cannot be referenced in filter or orderby as it is not indexed. Provide the 'Prefer: HonorNonIndexedQueriesWarningMayFailRandomly' header to allow this, but be warned that it may fail randomly. Add the header and the same request returns 200. Read the header’s name again before you reach for it, though. Somebody at Microsoft went to the trouble of putting “MayFailRandomly” in an API contract, and that is not the kind of thing you build a nightly job on. REST answering 500 for this is the one that annoys me. Filtering an unindexed column on a large list is a completely predictable, documented, business-as-usual refusal. It is not an internal server error. The REST Trick That Only Works Under 5,000 In the previous post I found something I didn’t expect: SharePoint REST will let you filter on an expanded lookup’s column, even one that isn’t the lookup’s ShowField. $filter=CustomerLookUp/customerNo eq 'C-NARROW'&$expand=CustomerLookUp At 1,000 items that returns 200 and it’s the single thing that got the OData route down to two requests. At 10,000 items it returns 500, in every scenario I threw at it, along with the batched version of the same call. So it’s a sub-threshold trick. It works beautifully right up until the list is big enough for it to matter, which is a fair description of a lot of SharePoint behaviour. If you have built anything on that shape, it has an expiry date measured in rows. The One That Lies to You RenderListDataAsStream is how you run a CAML join over REST, and in the last post it came through with the projected columns intact. Past the threshold it does something worse than failing. Asked for 100 matching rows out of 10,000, it returned 47. Asked for 5,000, it returned 2,504. One request, 200 OK, no error, no NextHref to follow, no indication that anything is missing. It stops when the scan window closes and hands you what it found so far as though that were the answer. A truncated result with a 200 on it is the worst outcome in this whole post. Everything else either works or tells you it didn’t. What It Costs in Memory This is the part I hadn’t measured before, and it’s the strongest argument in the post. Below, allocated managed bytes while answering the same question: 100 matching rows out of 10,000. Approach Filter Allocated CAML join, CSOM server 1.4 MB CSOM per list, reverse server 2.3 MB Graph, reverse server 2.6 MB Graph $batch, reverse server 3.1 MB SharePoint REST, forward client 11.5 MB SharePoint REST $batch, forward client 16.9 MB Graph, forward client 50.9 MB Graph $batch, forward client 55.6 MB CSOM batched client 188.0 MB CSOM per list, forward client 200.0 MB One hundred rows. Two hundred megabytes. The split is exactly the Filter column. Every approach that filters on the server materialises about a hundred rows. Every approach that filters in C# has to pull the whole list into memory first, then throw away 99% of it, and at 10,000 items that is 200 MB of allocation to produce 100 objects you keep. Your production lists are twenty times bigger than my test list. Two things surprised me here. Allocation does not rank the same as bytes on the wire: CSOM allocates roughly eleven times its payload because a ListItem is a heavy object with a field dictionary attached, so 17 MB of response becomes 200 MB of garbage. And the join is not the memory winner when you ask for everything, only when you ask for some. Unfiltered, lean OData that fetches nothing but lookup ids allocates 17 MB against the join’s 105 MB, because the join is honestly returning full rows and OData is returning integers. The Whole Field at 10,000 Items Filtering to 5,000 rows of 10,000, entering at the order tasks list, two hops to the customer: Approach Filter Requests Queries Median Allocated CAML join, CSOM server 2 2 1,096 ms 52.7 MB SharePoint REST $batch client 2 5 1,752 ms 17.0 MB SharePoint REST, forward client 5 5 2,021 ms 12.2 MB CSOM per list, reverse server 12 12 2,870 ms 97.7 MB CSOM batched client 2 5 3,110 ms 188.5 MB Graph $batch, reverse server 5 62 3,311 ms 162.9 MB CSOM per list, forward client 23 23 5,157 ms 200.5 MB Graph $batch, forward client 3 3 6,369 ms 56.0 MB Graph, forward client 5 5 9,052 ms 52.9 MB Graph, reverse server 62 62 13,970 ms 140.0 MB CAML join over REST - 1 1 wrong answer - SharePoint REST, reverse - - - HTTP 500 - SharePoint REST $batch, reverse - - - HTTP 500 - The join is still first, and at 10,000 items it is now first by a wider margin than at 1,000, because everything else has to page and it only has to page twice. Note what happened to Graph’s reverse walk: 62 requests. The or-chains that fitted in a handful of calls at 1,000 items become sixty-two at 10,000, and batching turns 62 round trips into 5 while leaving all 62 queries exactly where they were. Gotchas The exception message is localised. My tenant answers in Danish: Den forsøgte handling er ikke tilladt, fordi den overskrider grænsen for listevisning. My first probe matched on the string “list view threshold” and therefore never detected a single hit. Match on ex.ServerErrorTypeName == "Microsoft.SharePoint.SPQueryThrottledException" instead. Any retry logic or alerting built on the message text is broken in every tenant that isn’t English. without a cursor is a truncation, not a page. This is how you get a wrong answer with no error. One of my own arms returned 47 rows out of 100 because it read the first 5,000 items of a 10,000 item list and then filtered what it happened to have. Batching collapses queries, never pages. A cursor only exists once the previous response has arrived, so a batch can carry every list’s page one and then has to go again for page two. That’s true of CSOM’s ExecuteQuery, of SharePoint’s $batch, and of Graph’s. Graph’s docs suggest batching as a way around URL length limits; I measured the same 91 clause ceiling inside a batch as outside it, so that particular workaround doesn’t apply here. past 60 values is fine, and I’m one of the people who told you otherwise. I repeated the 60-value claim in the joins post and cited the archived 2013 article it comes from. On 10,000 items with an indexed lookup column I ran 50, 60, 61, 100 and 500 values, and every one of them returned. The 500-value hard cap is real: 501 still throws Value does not fall within the expected range. It’s the 60 that I can’t reproduce. The ceiling moves. One reverse-traversal arm hit the threshold in one scenario and sailed through the same shape of query in another. Large list throttling is a service-side feature with a time-of-day component, so “it worked this morning” is not a test result. Your own tooling will hit this too. The setup step of my harness crashed while verifying the data it had just seeded, because it ran an check with 10. Ten rows requested, still refused. I had written that check myself and still didn’t see it coming. Wrapping Up Reading a big list is easy on every API. Filtering it is where they separate, and they separate hard: CSOM throws something you can catch, REST throws a 500, Graph throws a 400 with instructions, and RenderListDataAsStream hands you a partial answer with a 200 on it. The advice from the previous post survives the threshold, with one line added. Make SharePoint do the join, set AllowIncrementalResults = true, and page with the cursor. It stays the fastest correct answer at 10,000 items, and it’s the only approach that filters server-side without resolving the ids yourself first, which past the threshold has stopped being a performance question and become a memory one. Everything else is holding your whole list in RAM to find a hundred rows in it, and it is doing that whether or not anyone measured it. --- ### CAML Joins Across Lists: One Query Instead of Four Forfatter: Jeppe Spanggaard Dato: 2026-08-15 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-caml-joins-across-lists/ I’ve been telling people to use CAML joins since 2025. I wrote a whole post about it. I put it in my pick-one playbook as a reason CSOM still owns list items. And I had never measured it. Not properly. Never all the reasonable ways of fetching the same data, against the same lists, on the same day. It’s always been case by case: this feels like the better solution here, that one felt slow last time. In my head, one call instead of four has to be faster, and that was enough. That’s not a benchmark. That’s a hunch I’d been repeating with confidence. So I built the comparison I should have built a year ago. It moved my numbers, and it also corrected something I’d been saying wrong for a year. Is a CAML join actually faster than four separate queries? Yes, and by more than I expected. Fetching 1,000 rows across a three-hop lookup chain, the CAML join needed 1 HTTP request and about 254 ms. The same rows through four separate CSOM queries took 6 requests and 1,192 ms, OData expands over SharePoint REST took 4 requests and 548 ms, and Microsoft Graph took 4 requests and 1,655 ms. The join wasn’t just fewer calls, it was 4.7x faster than the four-query CSOM version and 6.5x faster than Graph. The closest anything got was SharePoint REST with $batch, at 355 ms, and that one deserves its own paragraph rather than a footnote. Same Rows, Eight Ways Four lists, each pointing at the next through a lookup column: Main -> OrderTasks -> Orders -> Customers Every approach has to return the same thing for each of the 1,000 items in the main list: its id, its title, and the customerNo and customerName from a customer sitting three hops away. The join version, built with CAMLEX: const string OrderTasks = "ordertasks", Orders = "orders", Customers = "customers"; CamlexNET.Interfaces.IQuery query = Camlex.Query() .LeftJoin(x => x["OrderTaskLookUp"].ForeignList(OrderTasks)) .LeftJoin(x => x["OrderDetailLookUp"].PrimaryList(OrderTasks).ForeignList(Orders)) .LeftJoin(x => x["CustomerLookUp"].PrimaryList(Orders).ForeignList(Customers)) .ProjectedField(x => x["customerNo"].List(Customers).ShowField("customerNo")) .ProjectedField(x => x["customerName"].List(Customers).ShowField("customerName")) .ViewFields(["ID", "Title", "customerNo", "customerName"]); var items = mainList.GetItems(query.ToCamlQuery()); context.Load(items); context.ExecuteQuery(); What’s happening here? Each LeftJoin walks one lookup hop. PrimaryList is the list you’re coming from, ForeignList the one you’re going to, which is what lets hops two and three start somewhere other than the main list. Those arguments are aliases you invent, not list ids: they’re string, and the compiler will tell you so (cannot convert from 'System.Guid' to 'string') if you try otherwise. SharePoint resolves the actual list from the lookup column’s own configuration, so "orders" works exactly as well as a GUID. I checked, because I’d been passing GUIDs for a year without knowing they were only ever labels. ProjectedField pulls a column out of a joined list and gives it a name you can use as if it lived on the main list. ViewFields is not optional, and this is the part I’d been getting away with rather than getting right. A projected field that isn’t listed there comes back absent, with no error. Same query, same joins, one removed: the column is simply not on the item. ToCamlQuery(), not ToString(). Bare ToString() gives you and as two sibling roots with no around them, which isn’t a well-formed document at all. SharePoint accepts it anyway and hands back all 1,000 rows, minus the projected columns, because there’s no in a fragment. A silent wrong answer is worse than an exception, and I’d shipped that line. The values come back as FieldLookupValue, not string, even when the source column is plain text. The text is in .LookupValue, which trips people up the first time. Every approach below returned an identical result set once normalised, checked by hashing the rows and comparing against a hash computed from the seed data. The wire shapes differ wildly, as you’ll see. The data doesn’t, and that part matters more than the timings: a fast query that quietly returns 998 rows instead of 1,000 isn’t fast, it’s broken. One Fat Request Beats Four Lean Ones 1,000 items in the main list, 2 warmup runs discarded, 10 timed runs per approach, interleaved so no approach got a systematically better slot. Every arm asks for the minimum it needs and no more, and every arm is chunked or paged at the ceiling I measured for that specific API rather than at a number I liked the look of. That last part cost me a rerun, and I’ll come back to it. Approach HTTP requests Queries Median CAML join, CSOM 1 1 254 ms SharePoint REST $batch 1 4 355 ms CAML join, SharePoint REST * 1 1 427 ms CSOM batched, no join 1 4 518 ms SharePoint REST, OData expands 4 4 548 ms Microsoft Graph $batch 1 4 613 ms CSOM, four separate queries 6 6 1,192 ms Microsoft Graph 4 4 1,655 ms * RenderListDataAsStream, not GetItems. REST exposes CAML two ways and only one of them returns the projected columns, which is the next section and the single most useful thing I learned building this. The row also carries that endpoint’s view chrome, a couple of dozen fields per row that nothing can trim, so it is paying for bytes the CSOM join never sends. Graph needs a request per list because it can’t traverse a lookup. It isn’t quite empty-handed: select the lookup’s internal name and you get its display value alongside the id, so OrderDetailLookUp comes back as "Order 00000" next to OrderDetailLookUpLookupId: "1". That’s one column from the related item, for free, and it’s the one the lookup was configured to show. Any other column of that item, or another hop beyond it, needs its own request. My chain wants customerNo and customerName three lists away, and a lookup has exactly one ShowField, so Graph fetches all four lists and joins them in C#. Four requests, one per list. That number used to be seven, and the three extra ones were mine, not Graph’s: I had paged at $top=999 out of habit, because 999 is the ceiling stuck in my head from /users and directory objects. It isn’t a SharePoint number. Graph’s default here is 200 a page and it honours $top literally, which I only established by seeding a 1,200-item list and counting the first page: $top=999 gives exactly 999, $top=1001 gives exactly 1,001. Don’t read a 200 OK as a yes, either. $top=100000 is accepted too, and just returns the list. Graph never complains about an over-large $top, so a non-error tells you nothing and the only honest test is to hold more rows than you think the ceiling is. Mine ran out at 1,200 without finding one. The OData row is the interesting loser. It’s the leanest of the lot on the wire, because odata=nometadata is compact and I only selected the lookup id columns. It still loses on time, because four sequential round trips cost more than one fat one. Being economical doesn’t help when you’re economical four times in a row. CAML Isn’t a CSOM Feature, and I Had That Wrong Here’s the correction. I’ve been writing as though CAML joins were a CSOM capability, and framing the choice as CSOM versus REST. That’s wrong twice over. CAML is SharePoint’s query language, not a CSOM feature, and SharePoint REST will run it. There are two routes, and they don’t behave the same: POST /_api/web/lists(guid'')/GetItems { "query": { "ViewXml": "..." } } POST /_api/web/lists(guid'')/RenderListDataAsStream { "parameters": { "ViewXml": "..." } } They don’t share a body shape, which is the first small tax. GetItems takes an SP.CamlQuery under query; RenderListDataAsStream takes a parameter bag under parameters. Send odata=nometadata and drop the __metadata annotation the old docs show, or you get The property '__metadata' does not exist on type 'SP.CamlQuery'. RenderListDataAsStream runs the join and gives you the projected columns: { "Row": [{ "ID": "1", "Title": "Main 00000", "customerNo": "C-F0026", "customerName": "Filler Company 0026" }]} That’s the full three-hop join, over REST, one request. It’s the CAML join, SharePoint REST row in the table above, and it beats every approach that doesn’t join except one, which is the next section. That snippet is trimmed, though, and the trimming matters. RenderListDataAsStream doesn’t answer in the shape OData or CSOM do. The rows live under Row instead of value, every value is a string ("ID": "1", not "Id": 1), and each row arrives with a couple of dozen fields you never asked for: PermMask, FSObjType, UniqueId, ContentTypeId, SMTotalSize, ScopeId, owshiddenversion, and FileRef in four separate encodings. doesn’t trim any of it, and neither does RenderOptions: 2. I’d assumed that one was the “just the list data” flag; the docs define ListData as “Return list data (same as None)”, so it’s the default output under a more promising name. It’s a view-rendering endpoint, not an API, and you deserialize it accordingly. GetItems is where it gets nasty. Send the same join and you get 200 OK, the right number of rows, and no customerNo or customerName anywhere in the response. The columns are simply gone. Before you write to tell me I forgot : I didn’t, and I checked that specifically, because it’s the obvious answer and Microsoft is explicit that a projected field has to be named there too. It was in the request. The same , the same joins, the same projected fields come back fine over RenderListDataAsStream and fine over CSOM. Take the out over CSOM and the value disappears exactly as documented, so the mechanism works and this isn’t it. GetItems drops the projection with the request fully formed. I assumed that meant the join had been ignored. It hasn’t, and I made myself prove it rather than infer it from a row count. I put a on the projected customerNo and compared the returned item ids against the exact set my test data says should match: 10 of 1,000 at one value, 500 of 1,000 at another, and zero for a customer that doesn’t exist. All three came back as exact set matches, not just matching counts. The clincher is that customerNo isn’t a column on that list at all, so there is nothing local for the filter to resolve against; the projection is the only path. So the join executes server-side, the filter across three lists is correct, and only the projected columns are lost on the way out. GetItems is usable for finding items by a value two lists away. You just can’t read that value back, which makes it a fine filter and a useless projection. Graph has neither route. Be careful how you state that, though, because “Graph can’t do lookups” is too strong and I had it too strong myself until Rob Windsor’s post made me go and check. Graph reads a lookup’s value perfectly well. What it cannot do is treat that lookup as a path to the rest of the related item. I checked properly rather than taking anyone’s word for it. Ask Graph to expand a lookup and it tells you exactly what’s wrong: Parsing OData Select and Expand failed: Could not find a property named 'OrderDetailLookUp' on type 'microsoft.graph.listItem'. The metadata says the same thing more permanently. Pull https://graph.microsoft.com/v1.0/$metadata and listItem declares six navigation properties: analytics, documentSetVersions, driveItem, fields, permissions and versions. A lookup column is not among them, and could not be. Your columns live inside fields, declared as : an open type, whose properties are whatever your columns happen to be called. fields itself is a navigation property, which is why $expand=fields works at all, but nothing inside it is one, because a column invented at runtime can’t be declared in a schema. There is nothing for $expand to follow. That’s a design decision, not a gap in the documentation. Which is why you get the display value and nothing more. The lookup’s ShowField is copied into the field bag as a value, so it travels with the item. Everything else stays behind in the other list. One thing I did not test: SharePoint lets you tick extra columns when you create a lookup, under “Add a column to show each of these additional fields”. Those are secondary lookup columns on the list holding the lookup, and Graph models them as ordinary columns with lookup.primaryLookupColumnId pointing back at the primary, so they’d presumably ride along the same way. That only ever spans one hop, so it wouldn’t have rescued a three-list chain, but if your relationship is a single hop it’s worth knowing about before you write a second request. Batching Gets You the Round Trip, Not the Query Count This is the nuance I’d have missed if I’d only counted round trips, and it is where the join’s lead is narrowest. All three stacks can put several queries in one HTTP request. CSOM queues GetItems calls onto one context and sends them in a single ExecuteQuery. SharePoint REST has POST /_api/$batch, multipart and awkward but real. Graph has POST /v1.0/$batch, JSON and pleasant, capped at 20 sub-requests: the 21st comes back Number of requests inside batch exceed the limit. And batching works. Here is the row I did not want to find: HTTP requests Queries Response bytes Median CAML join, CSOM 1 1 988,195 254 ms SharePoint REST $batch 1 4 156,502 355 ms CAML join, SharePoint REST 1 1 1,020,047 427 ms The REST one is uglier than it should be, because SharePoint’s $batch is multipart rather than JSON. Each part is a whole HTTP request with its own headers, and the blank lines are load-bearing: POST /_api/$batch Content-Type: multipart/mixed; boundary=batch_a1b2c3 --batch_a1b2c3 Content-Type: application/http Content-Transfer-Encoding: binary GET https://the-tenant.example/sites/x/_api/web/lists(guid'')/items?$select=Id,Title&$top=5000 HTTP/1.1 Accept: application/json;odata=nometadata --batch_a1b2c3-- Repeat the part per query. The response comes back as multipart/mixed too, one HTTP/1.1 200 block per sub-request in the order you sent them, which you then have to pull the JSON out of yourself. Graph’s version is a JSON array of {id, method, url} and is far nicer to write, but it may reorder its responses, so key them by id rather than by position. On REST, batching four plain queries beats the join. Not on round trips, which tie at one, but on time and on an order of magnitude of bytes. The join is still the fastest thing overall, but “always join” is the wrong lesson: it’s “always join on CSOM”. The REST join loses because RenderListDataAsStream ships a megabyte of view chrome nobody asked for, and four lean OData queries in one envelope simply carry less. So batching genuinely buys the round trip. What it does not buy is the query count, and that column is the one to read. The join is one query. The batched arms are three or four, riding together. Whether SharePoint charges you per request or per operation is not something my data settles, and the throttling docs say resource units are counted per operation inside a batch, so I would not assume the envelope is free. Batching also can’t filter. It drags all four lists back whole, 3,051 rows to answer a question about 1,000 of them, because you don’t know which ids you need until the first response comes back. Which brings me to the claim I’ve been repeating for a year, and which this exercise did not so much confirm as dismantle. I’ve been saying “6 resource units versus 2”, on the basis that a multi-item query costs 2 resource units. Go back to the throttling page and read which sentence that table sits under: “Microsoft Graph APIs have a predetermined resource unit cost per request.” Much further down, past four more tables and into the “How to handle throttling?” section, the same page says “CSOM and REST don’t have a predetermined resource unit cost, and they usually consume more resource units than Microsoft Graph APIs to achieve the same functionality.” So the 2-units-per-query figure was never a price for the CSOM queries I was applying it to. My arithmetic borrowed Graph’s price list for a different shop. In fairness the same page does bless the number as an estimate, “you can estimate the request rate using an average of 2 resource units per request”, which is a reasonable way to size a request rate and not a per-call cost you get to multiply by four and quote as a fact. The direction of the argument survives, because fewer queries is still fewer queries. The certainty doesn’t. And I should say where I said it, because it’s still sitting there in my own writing. The 2025 joins post I linked at the top counts the units out query by query in its code comments and closes on “66% fewer resource units”. The CSOM performance playbook runs the same arithmetic. Both of them are wrong in the same way, this paragraph is the correction, and I’d rather point at them than quietly hope you don’t click through. I also can’t replace it with a measurement. The documented per-app limits are real and my measurements match them: 1,250 resource units per minute and 1,200,000 per 24 hours, which is the row for tenants up to 1,000 licences. What you can’t easily do is watch the meter. Microsoft’s developer blog says “when the application has consumed 80% of its resource unit quota SharePoint will start to send RateLimit headers”, and it never says which window that 80% is measured against. I found out by pushing: 1,416 requests in 14 seconds and the headers appeared, carrying RateLimit-Limit: 1250. That’s the per-minute budget, not the daily one. It’s also a state that evaporates in about ten seconds, which makes it useless as a measuring instrument. And here’s the part that made me stop trying. The same throttling page, in a section titled RateLimit headers, currently says: “SharePoint Online does not return or support IETF RateLimit headers.” I have them in a response body from this afternoon. The commit history explains it better than the page does: that section was headed “RateLimit headers - preview” until a docs change on 7 August 2026 deleted it, with the note that there had been a preview and it was no longer there. So what I caught was a preview being switched off underneath me, eight days before I went looking. Two sections higher the page still lists “Use the Retry-After and RateLimit HTTP headers” as a best practice, which it hasn’t got round to retracting. Honour Retry-After, treat a RateLimit header as a bonus that has already been withdrawn once, and don’t build a measuring instrument on either. It does mean nobody should be quoting per-call resource unit costs at you, including me. Then I Added a WHERE The part of the original post I like most is filtering on a field several lists away. So the second scenario queries the OrderTasks list and filters on the customer’s number, two hops out: CamlexNET.Interfaces.IQuery query = Camlex.Query() .Where(x => (string)x["customerNo"] == customerNo) .LeftJoin(x => x["OrderDetailLookUp"].ForeignList(Orders)) .LeftJoin(x => x["CustomerLookUp"].PrimaryList(Orders).ForeignList(Customers)) .ProjectedField(x => x["customerNo"].List(Customers).ShowField("customerNo")) .ProjectedField(x => x["customerName"].List(Customers).ShowField("customerName")) .ViewFields(["ID", "Title", "customerNo", "customerName"]); The generated Where targets the projected field directly, and the value type is Text, not Lookup: C-BROAD Before running it I assumed the join would lose here. Filtering first and walking backwards is the obvious smart move: find the customer, find their orders, find those orders’ tasks. Three tiny, highly selective queries instead of one join across the whole list. It doesn’t win. Filtering to 500 of 1,000 rows, the join answers in one request and 168 ms; the best reverse walk needs three requests and 365 ms, and the worst needs eight and 1,658 ms. The full table is below, at the shape you’re more likely to ship. Reverse traversal doesn’t collapse because the idea is bad. It collapses because neither REST nor Graph has an in operator for a set of ids, so 500 ids become a chain of or clauses chopped into pieces that fit inside a query string. Thirteen requests for REST, eight for Graph, which gets to send far longer chains, and I’ll come back to why. Batching rescues some of that, but less than you’d hope, and it’s worth being precise about why. The stages are sequential: you can’t ask for the orders until the customers have answered. So you can only batch within a stage, which is why REST reverse goes from thirteen requests to two and not to one. The thirteen queries are all still there. At the other end, filtering down to the 10 rows that match C-NARROW, reverse traversal finally gets somewhere: REST does it in 2 requests and 187 ms, batched or not. Still slower than the same filter as a CAML join over CSOM, which answers in 1 request and 108 ms. And look at what those ten rows cost on the wire: REST reverse moves 1,607 bytes, the CAML join moves 10,229, and CSOM walking forward moves 1,825,534. Same ten rows. That is what client-side filtering means. More Than One Customer, Which Is What You Actually Write A single Eq is the demo. Real code usually asks for a set: give me the tasks for these three customers. That’s an over the projected field, and it’s fair to ask whether the operator surface on a projected column is as complete as on a real one. It is. I checked , and against the ids my test data says should match, over both CSOM and REST, and all six runs came back as exact set matches: Operator on the projected customerNo Expected rows CSOM REST with three customers 520 exact exact 490 exact exact 500 exact exact So here’s the whole field, at the shape you’d actually ship: 520 rows belonging to three customers, and what it costs to get them without a join. The Filter column is the one that repays reading. Only the join and the reverse walks filter on the server. Every client row fetches the entire entry list and discards what doesn’t match with a Contains in C#, because customerNo lives two hops from the list being queried and nothing but the join can express that predicate to SharePoint. A forward arm that filtered server-side would have to resolve the customer ids first, at which point it has become a reverse arm. That isn’t a straw man I built, it’s the shape of the problem. Approach Filter HTTP requests Queries Median CAML join, CSOM server 1 1 169 ms SharePoint REST $batch client 1 3 255 ms CAML join, SharePoint REST * server 1 1 300 ms CSOM batched, no join client 1 3 351 ms SharePoint REST, OData forward client 3 3 367 ms SharePoint REST $batch, reverse server 2 13 377 ms CSOM per list, reverse server 4 4 455 ms Microsoft Graph $batch client 1 3 509 ms CSOM per list, forward client 4 4 690 ms Microsoft Graph $batch, reverse server 3 9 732 ms Microsoft Graph, forward client 3 3 1,149 ms SharePoint REST, OData reverse server 13 13 1,163 ms Microsoft Graph, reverse server 9 9 1,717 ms * RenderListDataAsStream again. The join answers in 169 ms and one request. The cheapest thing that isn’t a join costs 1.5x that and only by giving up the filter, the honest reverse walk costs 2.2x, and the worst case is 10x and nine round trips. Worth being precise about why the reverse arms blow up, because “no in operator” is only half of it. They do use or, and it works fine: $filter=(OrderDetailLookUpId eq 2) or (OrderDetailLookUpId eq 3) or .... What kills them is that the chain has to fit in a query string, not a URL, and each stack has its own idea of how long that may be. I bisected both rather than assuming they matched: SharePoint REST refuses at 2,048 characters of query string, which is ASP.NET’s maxQueryStringLength default showing through. 46 clauses go through at 2,016 characters, 47 come back 401 at 2,059. Graph goes more than twice as far, to somewhere just past 4,625 characters. 91 clauses pass, 92 come back 404 with an empty UnknownError and no explanation at all. Each escaped clause costs about 43 characters either way, so REST fits about 44 ids per request and Graph about 86. An in operator would cost about 6 characters per id, which is why its absence hurts: same query, seven times the requests. This is the rerun I owed you. I had originally chunked both stacks against a single 1,900 character budget measured on the whole URL, which is wrong twice: the cap is on the query string, and Graph’s path carries a 90 character site id that was eating into a budget SharePoint never had to pay. Fixing it took Graph’s reverse walk from nineteen requests to nine, which is the sort of correction that makes your own argument weaker and your numbers worth reading. Three customers become 520 order tasks. REST needs twelve chunked calls to read them, on top of the one that found the orders. Thirteen round trips to answer one question. The more you ask for, the worse not joining gets. What REST and Graph Actually Refuse I checked the walls rather than assuming them, and the errors are worth having: Two-level $expand in REST: 400. The docs never spell out a depth limit, but they do warn you off the shape, in one sentence you could read past: “Bulk expansion and selection of related items isn’t supported.” In practice you name one field of one lookup and that’s your lot. Reaching a lookup id through an expand ($select=OrderTaskLookUp/OrderDetailLookUpId): also 400. If this worked, OData would do the whole chain in two calls. $filter=Id in (1,2,3) in REST: 400. No in operator, hence the or chains. Graph filtering on a column that lives on the lookup target: 400. You can only filter fields/CustomerLookUpLookupId, the integer. Graph in (...) on a lookup id: 400. But or chaining works, which is the only reason Graph’s reverse arm is nine requests and not 500. Graph expanding a lookup at all: 400, “Could not find a property named ‘OrderDetailLookUp’ on type ‘microsoft.graph.listItem’”. Try it inside fields instead and the type name changes but the answer doesn’t: $expand=fields($expand=OrderDetailLookUp) is 400, “Could not find a property named ‘OrderDetailLookUp’ on type ‘microsoft.graph.fieldValueSet’”. Asking for a path through a lookup, $expand=fields($select=OrderDetailLookUp/Title): 200, which looks like a win until you read it. fields contains exactly one thing, "OrderDetailLookUp": "Order 00000", the lookup’s own display value. The /Title was quietly ignored rather than honoured or rejected. REST does have one trick I didn’t expect: you can filter on an expanded lookup’s column, even one that isn’t the lookup’s ShowField. $filter=CustomerLookUp/customerNo eq 'C-NARROW' with $expand=CustomerLookUp returns 200. That’s what gets the OData route down to 2 requests on the narrow filter. Gotchas GetItems returns your join without the projected columns. 200 OK, correct row count, columns missing, present and correct. The filter still works, so if you only need the ids, it’s fine. If you need the values, use RenderListDataAsStream. A projected field must also be in . Leave it out and the column isn’t on the item. No error, no warning, no empty string, just absent. CAMLEX won’t add it for you either. ProjectedFields is a whitelist, and it’s shorter than the docs let on. I provisioned one column of each disputed type and tried to project it. Text, Number, DateTime and Currency came through. Choice, person, yes/no, hyperlink and every flavour of multi-line text failed, including the plain one-line Note that Microsoft’s own list says is allowed. The error is Value does not fall within the expected range, which tells you nothing about which column it means. My workaround is to store the value in a plain text column, because I know that projects, and then put column formatting on it so the user still gets the coloured pill or the icon they’d have got from a choice column. Same experience in the list, and the column survives a join. LookupId="TRUE" or you’re filtering on display text. Filter a lookup column without it and CAML compares against the shown value, not the id. You get zero rows and no error, which is the worst combination. The In clause has two limits, not one. 500 values really is a hard cap: 500 returns, 501 throws Value does not fall within the expected range. The other one, repeated everywhere and traced to an archived 2013 article, is 60 values, past which SharePoint is said to stop treating the column as indexed so a big list throws the list view threshold error instead. My lists are 1,000 items, so I never met that one and I would not take it on faith. At 1,000 items my no-join arm went from 4 requests to 6 purely because of that chunking. SharePoint REST pages at 100 by default, Graph at 200. Forget $top and a 1,000 item list is ten round trips before you’ve done anything interesting. 999 is not a SharePoint number. It’s the directory-object ceiling from /users, and I paged a whole benchmark at it out of habit. Graph honours $top literally on list items: 999 gives 999, 1,001 gives 1,001. It also returns 200 OK for $top=100000 without honouring anything in particular, so a non-error tells you nothing. If you want to know your real page size, put more rows in the list than you think the cap is and count the first page. The query string cap is 2,048 characters on SharePoint, and mine answered 401. Not 414, and not the 400 that ASP.NET’s own docs promise for this. You get “The length of the query string for this request exceeds the configured maxQueryStringLength value” under an Unauthorized status code, which sends you off debugging your token. Push much further past it and the status changes again, to 404. An escaped or clause costs about 43 characters, so plan on roughly 45 ids per request. Graph’s ceiling is its own, and roughly twice SharePoint’s. 4,625 characters, so about 86 ids per request, and it announces the limit with a bare 404 and an empty UnknownError. If you chunk both stacks against one number you will silently hand Graph half the requests it needed, which is exactly what I did for the first draft of this post. RenderListDataAsStream is not a drop-in swap for the OData shape. Rows under Row, not value. Every value is a string, ids included, so parse rather than cast. And you get the view’s own fields whether you want them or not: RenderOptions: 2 removed nothing for me, and the docs say why, since ListData is defined as “same as None”. CAMLEX still works, and ToString() is not the method you want. Camlex.Client.dll 5.4.3 built and ran fine against CSOM on .NET 10, which I mention because the last NuGet release is from July 2024 and people ask. Use ToCamlQuery(); ToString() returns fragments, and SharePoint will run them and quietly leave your projections out. Wrapping Up The advice survives contact with a stopwatch, but not in the shape I brought to it. Two things didn’t survive. I’d been selling the join as a CSOM feature and it isn’t: CAML runs over REST too, and comes through intact on RenderListDataAsStream. And “the join always wins” is now false, because on REST a $batch of four ordinary queries beats the REST join on time and on bytes. What’s left is narrower and I think truer: the CAML join over CSOM is the fastest way to do this, and the only one that keeps the filter on the server without walking the chain backwards first. The honest caveats: one tenant, one geography, warm caches, ten timed runs per approach, so anything inside about 20% is noise. Absolute numbers won’t be yours. The ratios probably will be. And the gap grows with the ask. One customer, one row set, and the alternatives are merely slower. Three customers and 520 rows, and the reverse walks turn into thirteen and nine round trips, not because or doesn’t work but because 520 ids don’t fit in one query string on either stack. Those are the numbers after I tuned every arm to the ceiling I measured for it: at its 500-value cap, SharePoint’s or-chains at 2,048 characters, Graph’s at 4,500 just under its measured 4,625, Graph’s pages at a $top that actually returns the list. The first draft of this post had all three set to something I’d guessed, and every one of the guesses happened to favour the join. Fixing them cost the join some of its margin and cost me an afternoon, and it’s the only version of the table I’d defend. My rule of thumb, now with receipts behind it and one honest amendment: if your lists are connected by lookup columns, make SharePoint do the join. If you can’t, batch. The round trips come back either way, but the join is the only thing that filters server-side without making you resolve the ids yourself first, and that’s the part you’d otherwise have written by hand in C# while holding an entire list in memory to do it. Every number above is from lists of 1,000 items. Past 5,000 the rules change, one of these approaches starts returning partial answers with a 200 OK, and the REST filter trick stops working entirely. I ran the whole thing again at 10,000: what still works at the list view threshold. --- ### One Message, One Site: Deterministic Instance IDs in Durable Functions Forfatter: Jeppe Spanggaard Dato: 2026-08-15 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-durable-functions-deterministic-instance-ids/ My provisioning flow starts with a Service Bus message: a customer was created in the business system, a message lands on the provisioning queue, a Functions trigger picks it up and schedules a Durable Functions orchestration that builds the customer’s SharePoint site. Now walk through the failure case with me. The trigger receives the message. It schedules the orchestration. And then, before it can complete the queue message, the process dies. Deployment restart, scale-in, hardware burp, doesn’t matter. Service Bus does exactly what it promises: the message lock expires and the message comes back. A fresh trigger invocation receives it and schedules the orchestration. Again. Two orchestrations, one customer, and a race to see which one creates the site first. The other one gets to fail in interesting ways for the next ten minutes. The Trigger, Guard by Guard Here’s the subscriber, trimmed to the part that matters: // Deterministic instance ID: if the process dies after scheduling but before completing the // message, Service Bus redelivery hits the already-existing instance instead of provisioning twice. var instanceId = $"provision-{message.MessageId}"; var existing = await client.GetInstanceAsync(instanceId, cancellation: cancellationToken); if (existing is not null) { logger.LogWarning( "Orchestration {InstanceId} already exists (status {Status}); completing redelivered message without rescheduling.", instanceId, existing.RuntimeStatus); await messageActions.CompleteMessageAsync(message, cancellationToken); return; } await client.ScheduleNewOrchestrationInstanceAsync( nameof(ProvisioningOrchestrator), new OrchestratorInput(schema, ListItemId: listItemId, CustomSteps: CustomProvisioningSteps.Build()), new StartOrchestrationOptions { InstanceId = instanceId }, cancellationToken); await messageActions.CompleteMessageAsync(message, cancellationToken); What’s happening here? The instance ID is computed from message.MessageId, so the same message always maps to the same orchestration instance. No randomness anywhere in the path from message to instance. GetInstanceAsync asks the task hub whether that instance already exists. On first delivery it doesn’t; on redelivery it does, in whatever state the first attempt left it - Running, Completed, even Failed. If it exists, the trigger completes the message and does nothing else. The redelivery was a symptom of the earlier crash, not a request for another site. Only when the instance is genuinely new does the trigger schedule it, passing the deterministic ID through StartOrchestrationOptions. The message is completed after scheduling. That ordering is the whole point, and it’s worth dwelling on. The Order of Operations Is the Design There are only two possible orderings, and both can crash in the middle: Complete first, then schedule. A crash between the two loses the message forever - Service Bus considers it done, but no orchestration exists. A customer quietly never gets a site. Nobody notices until they complain. Schedule first, then complete. A crash between the two causes a redelivery of a message whose orchestration already exists. Without the dedupe check, that’s a duplicate provisioning. With it, the redelivery is a no-op. So you pick the second ordering, accept that duplicates are now possible, and make them harmless with the deterministic ID. This is the standard shape of at-least-once messaging: you can’t prevent redelivery, you can only make redelivery boring. One prerequisite hiding in the function attributes: AutoCompleteMessages = false on the Service Bus trigger. With auto-complete on, the runtime settles the message on its own schedule and the careful ordering above is fiction. Explicit completion also means explicit dead-lettering - my trigger dead-letters messages with an unknown event type or a customer that doesn’t exist, instead of letting them retry into oblivion. Choosing the Key: Delivery Identity vs Business Identity provision-{message.MessageId} dedupes deliveries of one message. Same message redelivered: blocked. But if the upstream system sends two distinct messages for the same customer (double-click on “create”, integration replay, someone reruns a job), each has a fresh MessageId and both provision. My local dev trigger, an HTTP endpoint for testing, actually uses a different scheme: provision-{listItemId}, keyed on the customer’s row in the intake list. That’s a business key. It dedupes at the customer level, no matter how many messages or HTTP calls ask for it. So which is right? Depends on what “again” should mean: Delivery key (MessageId): re-sending a message later does provision again. Right when upstream legitimately re-requests provisioning, such as a retry after a fixed config. Business key (customer ID): one provisioning per customer, ever, until the old instance is purged. Right when duplicates upstream are common and re-provisioning is the rare, deliberate case. I run the delivery key in production because my retry story goes through an explicit resume flow rather than “send the message again”, and the business system already guards against duplicate customer events. But I’d flip to a business key in a heartbeat if the upstream were flakier. The point is that the instance ID is not a cosmetic label - it’s the dedupe boundary, and you should choose it on purpose. Whatever you pick, keep it deterministic and keep it readable. provision-4711 in the portal tells you which customer at a glance; a random GUID tells you nothing. Telling the Caller What Happened Because the instance ID is predictable, everything downstream gets simpler. The dev trigger returns the standard management payload: return new AcceptedResult(location: null, value: client.CreateHttpManagementPayload(instanceId)); That’s a 202 with ready-made URLs for polling status, raising events, and terminating the instance. And since my orchestrator publishes its progress as custom status after every step, polling that URL shows exactly which provisioning step is running. Support can check on “the provisioning for customer 4711” without knowing anything about task hubs, because the instance ID is guessable from the customer. Gotchas A Failed instance also blocks its ID. GetInstanceAsync returns failed and terminated instances too, and scheduling over an existing instance ID doesn’t reliably give you a clean second run. If “retry the failed provisioning” is a flow you need (it is), make it explicit: purge the old instance or start a new instance ID that carries a resume checkpoint. Deciding this after the first production failure is the wrong time. Ask me how I know. The check-then-schedule pair isn’t atomic. Two redeliveries processed concurrently could both pass the GetInstanceAsync check. In practice my queue runs with maxConcurrentCalls: 1 so this doesn’t occur, but if you crank concurrency, know that the guard is best-effort and the deterministic ID is what actually prevents two distinct instances - the second schedule call targets the same ID rather than minting a new orchestration. Keep dev and prod schemes consciously different, or consciously the same. Mine differ (delivery key in prod, business key in dev), which is fine because I know why. Discovering by accident that your environments dedupe differently is less fine. Instance IDs live until purged. With a business key, “one per customer, ever” includes instances from last year. Build purging into your lifecycle (mine purges on cancellation) or old completed instances will block legitimate re-provisioning. Wrapping Up With at-least-once delivery, the question is never whether you’ll see a duplicate, only what it costs you. Derive the instance ID from the message, check for the existing instance before scheduling, and complete the message last. Then the answer is: one log line. Instance IDs only make sense once orchestrations do. If this is your first stop, start with Durable Functions: A Function That Sleeps for a Week. --- ### Graph Multi-Value Lookups: The LookupId Shape That Works Forfatter: Jeppe Spanggaard Dato: 2026-08-10 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-graph-multi-value-lookup-person-fields/ I had multi-value lookup columns filed under “Graph can’t do that” for embarrassingly long. I was wrong. It can. The syntax just looks nothing like what you’d write in CSOM, and the request that gets it wrong doesn’t tell you. The Shape PATCH /sites/{site-id}/lists/{list-id}/items/{item-id}/fields { "ProductsLookupId@odata.type": "Collection(Edm.Int32)", "ProductsLookupId": [6, 7, 8] } What’s happening here? The writable property is the column’s internal name plus LookupId, not the column name. Graph documents this for reads and is silent about writes, but it’s the same convention. The value is a plain array of integer ids from the target list. No objects, no LookupValue, no wrapper. The @odata.type annotation sits in a sibling property with the same name. Some report it isn’t strictly required. Include it anyway - it costs nothing and it’s the difference between a write and a shrug. Creating an item works the same way, with the body nested under fields. Clearing the column is "ProductsLookupId": []. The failure mode worth knowing: send the CSOM-flavoured Collection(SP.FieldLookupValue) with a results wrapper and you get 204 No Content and a column that stays empty. Nothing in the response suggests you did anything wrong. Person Columns Are Just Lookups A Person or Group column is a lookup into the hidden User Information List, so the write is identical: { "ReviewersLookupId@odata.type": "Collection(Edm.Int32)", "ReviewersLookupId": [12, 13, 27] } Single-value version is { "ReviewerLookupId": "12" }. The catch is where those numbers come from. They’re site-collection user ids, not Entra object ids and not Graph user ids. Graph has no /sites/{id}/users endpoint, so you read them out of the hidden list yourself: GET /sites/{siteId}/lists?$filter=displayName eq 'User Information List' GET /sites/{siteId}/lists/{uilId}/items?$expand=fields($select=id,EMail,Name,Title) And then the real gap: if a user has never been referenced on that site, they have no entry in the list and therefore no id, and Graph offers no way to create one. That’s EnsureUser, and it only exists in SharePoint REST and CSOM: POST https://{site}/_api/web/ensureuser { "logonName": "i:0#.f|membership|someone@the-tenant.example" } So the write path for person columns can’t be pure Graph unless you can guarantee the users already exist on the site. In a backend I skip the dance and do the whole thing in CSOM with Web.EnsureUser() and a FieldUserValue. In a browser client I resolve or create with the REST call, then write the item with Graph. Reading Them Back Lookup fields aren’t returned by default, and the display value needs an explicit $select inside the $expand: GET /sites/{site-id}/lists/{list-id}/items?$expand=fields($select=id,Title,ProductsLookupId,Products) You get the id and the display value, and that’s it. Any other column from the target list is a second query. Twelve lookup fields per query is the documented ceiling. Gotchas Files.ReadWrite.All alone makes multi-value lookups read back as []. Populated column, empty array, no error. Add Sites.Read.All and the values appear. Single-value lookups are unaffected, which is what makes this one so slow to diagnose. Internal names, not display names. Product_x0020_Line, not Product Line. Get them from /lists/{id}/columns. 400 on the array means the column is single-value. Check lookup.allowMultipleValues or personOrGroup.allowMultipleSelection on the column definition before blaming the syntax. It’s logonName, not loginName. Get it wrong and ensureuser answers with InvalidClientQueryException, which tells you nothing about the spelling. ensureuser needs a SharePoint-audience token. https://{tenant}.sharepoint.com/.default, not your Graph token. Two audiences, two sets of permissions to consent. Old Graph SDK 5.x chokes on non-string AdditionalData. CurrentDepth (1000) is equal to or larger than the maximum allowed depth of 1000 on anything that isn’t a string, ints and arrays included. Fixed in current packages, so upgrade first and only reach for raw JSON through the request adapter if you’re pinned. Wrapping Up Multi-value lookups and person columns are writable from Graph. The rule is LookupId plus an integer array plus Collection(Edm.Int32), and the CSOM-shaped payload you’d expect to work is exactly the one that fails quietly. The one thing Graph genuinely can’t do here is mint a user id that doesn’t exist yet. That’s still an EnsureUser call, and it’s part of why I keep a playbook for which SharePoint API I reach for instead of committing a whole feature to one of them. --- ### Kontraktstyringssoftware: købe et system eller bygge på Microsoft 365? Forfatter: Dan Toft Dato: 2026-08-07 URL: https://pinksky.dk/blog/2026-08-07-kontraktstyring-software/ Kontraktstyringssoftware dækker over tre ret forskellige indkøb, og det er værd at vide, hvilket af dem I sidder og kigger på. I kan købe et dedikeret kontraktstyringssystem, der står for sig selv med sin egen brugerdatabase. I kan købe et produkt, der lægger sig oven på jeres Microsoft 365. Eller I kan bygge overblikket i den platform, I allerede betaler for. Vi anbefaler som regel den tredje vej, og resten af indlægget handler om de spørgsmål, der afgør, om den også er svaret hos jer. Hvad er kontraktstyringssoftware? Et dedikeret kontraktstyringssystem er bygget til kontrakter og ikke til andet, og det er kategoriens styrke: klausulbiblioteker, forhandlingsforløb med flere parter, standardrapporter og en begrebsverden, en juridisk afdeling kan genkende. Der findes flere dygtige produkter i den kategori, og de kommer med et færdigt sprog for kontrakter, som ingen internt skal opfinde. Prisen er, at kontrakterne ligger i leverandørens system, og at I får endnu et sted at administrere brugere og adgange. Den anden kategori ligner den første i et salgsmøde, men er noget andet: et produkt, der bygger på Microsoft 365 og sælges med den pointe, at kontrakterne bliver hos jer. Nogle af dem lægger faktisk kontrakterne i jeres eget SharePoint. Andre bruger jeres tenant som ramme og holder kontraktdataene i deres egen database ved siden af, og så er I tættere på den første kategori, end tilbuddet lyder til. Den tredje er den kedelige, og det er ment som ros. Et samlet overblik med stamdata på hver kontrakt, styr på hvem der kan se hvad, versionshistorik og automatiske påmindelser inden fristerne er standardfunktioner i de værktøjer, I allerede har licens til. Det er stort set den samme liste, en produktdemo er bygget op om. Vi har beskrevet nærmere, hvad et kontraktstyringssystem skal kunne, og hvordan det ser ud bygget på Microsoft 365. Hvor mange kontrakter har I, og hvem skal kunne se dem? Antallet er det første, alle spørger om, og det afgør sjældent noget. Et par hundrede leverandøraftaler, kundekontrakter og ansættelseskontrakter presser ikke nogen platform. Det, der presser, er adgangene: ansættelseskontrakterne må HR og lederen se, kundekontrakterne skal salg og økonomi kunne finde, og leverandøraftalerne har typisk en ansvarlig hver. Er svaret på “hvem må se hvad” utydeligt i dag, bliver det ikke tydeligere af at flytte kontrakterne over i et nyt system. Der ligger samtidig en mekanisme i et dedikeret system, som er værd at få øje på inden underskriften. Når adgang koster en licens pr. bruger, bliver den naturlige reaktion at holde antallet af brugere nede, og så ender kontrakterne med at være tilgængelige for de tre, der har en licens, frem for de 40, der en gang imellem skal slå noget op. Det er præcis det problem, I ville løse. Bygger I på jeres eget Microsoft 365, følger adgangen de grupper, I i forvejen administrerer, og en ny kollega i økonomi ser det rigtige, fordi vedkommende er i den rigtige gruppe. Skal kontrakterne kunne underskrives elektronisk? Det er det krav, der oftest bliver brugt som argument for, at der skal købes et system, og det holder ikke længere. Microsoft 365 har sin egen indbyggede tjeneste til elektronisk underskrift: I sender et dokument til underskrift hos både interne og eksterne modtagere, det bliver liggende i jeres eget miljø hele vejen, og der er et revisionsspor over, hvem der har set, underskrevet eller afvist hvad. Den afregnes efter forbrug frem for som en licens pr. medarbejder. Den er værd at kende, men vi skal være ærlige: den er ikke god nok til, at den bliver vores anbefaling. Vores anbefaling er Penneo. Det er den udbyder, vi oftest møder hos danske virksomheder, der har brug for juridisk holdbare underskrifter, og vi har bygget integrationen mellem Microsoft 365 og Penneo før. Konceptet er det samme uanset udbyderen: kontrakten sendes til underskrift fra det sted, den i forvejen ligger, og det færdigt underskrevne dokument lander automatisk tilbage i kontraktarkivet på den rigtige aftale. Underskrifter er med andre ord en integration, ikke et argument for at købe et helt system, og det er værd at få af bordet tidligt, fordi det ellers kommer til at bære en beslutning, det ikke kan bære. Hvad koster endnu et system, når det hele er regnet med? Et tilbud indeholder licensen og opsætningen. Det, der ikke står i det, er de steder mere, I skal vedligeholde: endnu et login medarbejderne skal huske, og endnu en adgangsopsætning, der skal gennemgås, hver gang nogen skifter afdeling eller siger op. Bygger I på jeres eget Microsoft 365, løber der ingen licens oveni den, I allerede betaler, og investeringen ligger i stedet i at få stamdata og processer til at passe til jeres aftaler. Den største omkostning står der til gengæld ikke noget om i noget tilbud. Det er den, I betaler, hvis kontraktoverblikket dør: ingen har fået ansvaret for at opdatere udløbsdatoer, halvdelen af sidste års aftaler kom aldrig ind, og et forkert overblik er værre end intet overblik, fordi folk tror på det. Kontraktarkiver dør sjældent af manglende funktioner, de dør af manglende vedligehold. Derfor er automatiske påmindelser og godkendelsesflows den del, der reelt afgør, om løsningen stadig er opdateret om to år, uanset hvilken af de tre veje I vælger. Skal behovet stadig sælges internt, har vi skrevet om, hvad manglende kontraktstyring koster. Hvor lever jeres kontraktdata henne? Det er det spørgsmål, der sjældent bliver stillet, og det er det, I stadig kan stille en leverandør om en uge. Bed dem pege konkret på, hvor en enkelt kontrakt og dens udløbsdato fysisk ligger, frem for at tage “det ligger i SharePoint” for et svar. Det første, svaret afgør, er søgningen og Microsoft 365 Copilot: ligger kontrakterne i jeres eget Microsoft 365, kan Copilot læse med inden for den enkelte medarbejders adgange og resumere en aftale eller fremhæve de væsentlige vilkår, uden at der skal bygges noget. Ligger de i et selvstændigt system, kræver det en forbindelse, der skal etableres, vedligeholdes og betales for. Det andet er, hvad I har tilbage den dag, abonnementet stopper. Ligger kontrakterne hos jer, er der ikke noget at flytte. Ligger de andetsteds, er svaret en eksport, og en eksport er sjældent det samme som et overblik. Byg på det, I allerede betaler for Vi ville i langt de fleste tilfælde bygge kontraktstyringen på Microsoft 365. Kontrakterne bliver liggende hos jer, adgangen følger de brugere og grupper, I allerede administrerer, der løber ingen licens pr. medarbejder oveni den I allerede betaler, og næste ønske på listen kan I sige ja til selv frem for at vente på, at en tredjepart prioriterer det. Der findes tilfælde, hvor et dedikeret system er det rigtige. Forhandler I kontrakter som en hovedbeskæftigelse, med klausulbiblioteker, versioner frem og tilbage med modparten og et fagsprog, der skal matche resten af branchen, køber I et værktøj til et fag frem for et arkiv med påmindelser. Det, I betaler for der, er sjældent funktionerne. Det er en andens læsning af, hvordan aftaler skrives i jeres branche, og et løfte om at holde klausulbiblioteket opdateret, når standarderne bliver revideret. Har I selv den faglighed i huset, er der ikke meget tilbage at købe, for et branchespecifikt krav kan lige så godt bygges på den platform, I allerede ejer, formet efter hvordan I rent faktisk arbejder frem for efter en skabelon, der skal passe alle i faget. Det er undtagelsen frem for udgangspunktet, og vi siger det gerne, når det er svaret. For de fleste danske virksomheder er behovet et samlet overblik, styr på adgangene og en påmindelse i god tid, og det behov kræver ikke et nyt system. Hvor meget af arbejdet I selv laver, bestemmer I til gengæld selv. Har I folk til det internt, kan I sagtens bygge det i huset og bruge os til sparring undervejs, for eksempel et kig på stamdata og adgangsmodellen, inden der ligger 2.000 kontrakter i den. Skal vi tage hele opgaven, gør vi det lige så gerne. Og sidder I allerede med et konkret tilbud, kigger vi det gerne igennem som en uvildig second opinion og siger ærligt, hvad vi ville gøre, også hvis konklusionen er, at tilbuddet er fint som det er. Hos Pinksky bygger vi kontraktstyring på Microsoft 365 for en række danske virksomheder - uden nye licenser, og med det mål at I selv kan vedligeholde løsningen bagefter. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### Every Activity Will Run Twice: Idempotency in Durable Functions Forfatter: Jeppe Spanggaard Dato: 2026-08-05 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-08-durable-functions-idempotent-activities/ My provisioning engine creates SharePoint team sites through a Durable Functions orchestration: create the site, activate features, pull content types, apply templates, seed folders. Every activity call in that orchestrator goes through one shared retry policy: // Outer safety net: retry any activity that throws a transient error (e.g. throttling // that slips past HTTP-level retry). Exponential backoff: 5s, 10s, 20s, ... up to 5min, // 10 attempts total. All activities are idempotent, so retrying the full activity is safe. private static readonly TaskOptions ActivityRetryOptions = new( new RetryPolicy( maxNumberOfAttempts: 10, firstRetryInterval: TimeSpan.FromSeconds(5), backoffCoefficient: 2.0, maxRetryInterval: TimeSpan.FromMinutes(5))); Read that comment again: “All activities are idempotent, so retrying the full activity is safe.” That sentence is either the best thing about the whole engine or a lie that duplicates customer sites at 2am. There’s no middle ground. The retry policy reruns the entire activity, not the line that failed. If your activity created a site and then died resolving its ID, the retry creates the site again - unless the activity was written to survive being run twice. So this post is a catalog of how I actually make that sentence true. Five patterns, all from real provisioning code. Pattern 1: Check Before Create The site creation activity is the highest-stakes one. If a previous attempt created the site but failed a moment later (say, while resolving the Graph site ID), the retry must not create it again. So it probes first: // Idempotency: if the site already exists (e.g. previous attempt created it but // failed during Graph ID resolution), skip creation and go straight to Graph lookup. if (await SiteExistsAsync(newSiteUrl)) { logger.LogInformation("Site already exists at {SiteUrl}, skipping creation.", newSiteUrl); } else { await CreateSiteAsync(parsedType, site, siteAlias, newSiteUrl); } Note what makes the probe possible in the first place: the site URL is deterministic, built from a prefix and alias in the schema. No timestamps, no random suffixes. If the URL contained Guid.NewGuid(), attempt two would probe a different URL, find nothing, and create a second site. Deterministic naming is the quiet prerequisite for check-before-create. Pattern 2: “Already Exists” Is Success Sometimes you can’t probe cheaply, so you attempt the operation and translate the failure. Activating a site feature that’s already active comes back from SharePoint as an error - but it means the activity already did its job on a previous run: catch (InvalidOperationException ex) when ( // "Feature already activated" comes back as HTTP 500 with odata.error.code // "System.Data.DuplicateNameException" - locale-independent, unlike the human message. ex.Message.Contains("System.Data.DuplicateNameException", StringComparison.OrdinalIgnoreCase)) { logger.LogInformation("{Feature} feature is already active, skipping.", displayName); } The comment is a scar. My first version matched on the English error text, which works great until the code runs against a tenant in another language. Match on error codes, not error messages. The OData error code survives localization; “Feature … is already activated” does not. Graph makes this pattern cleaner because it gives you a real status code. Copying folder structures into the new site: catch (ODataError ex) when (ex.ResponseStatusCode == 409) { // Item already exists in target - idempotent on retry logger.LogInformation("Item '{ItemName}' already exists in target, skipping copy.", itemName); return; } 409 Conflict on a create is not a failure. It’s a receipt from your previous attempt. Pattern 3: Upsert by Nature The best idempotency is the kind you get for free by choosing the right API. The registration activity writes the new site’s URL and ID back to an inventory list, and it does so with SharePoint’s ValidateUpdateListItem against a known item ID. Setting the same fields on the same item to the same values twice is indistinguishable from doing it once. There’s nothing to guard because the operation has no failure mode for repetition. When you’re designing an activity and you get to pick between “add a row” and “set fields on item X”, pick the second. Every Add needs a guard; a keyed Set guards itself. Pattern 4: Verify After Write The dark twin of idempotency: some SharePoint writes report success and then don’t stick. Property bag values on a freshly created site are notorious for this. The activity’s answer is to re-read everything it just wrote and throw if reality disagrees: if (mismatches.Count > 0) { throw new InvalidOperationException( $"Property bag verification failed on {siteUrl}: {string.Join("; ", mismatches)}. " + "Values did not persist to the server; retrying the activity."); } What’s happening here? After writing the property bag values, the activity loads Web.AllProperties fresh from the server. Each expected key/value is compared against what actually persisted. Any mismatch throws a plain transient exception, which hands the problem to the orchestrator’s retry policy. The rerun is safe precisely because setting a property bag key is an upsert - pattern 3 again. Verification and idempotency are two halves of the same loop: writes are repeatable, so failed verification can simply demand a repeat. This inverts the usual relationship with retries. Instead of retries being something inflicted on the activity, the activity uses a throw to request one. Pattern 5: Idempotent Deletes Too Removal flows have the same problem in the other direction. Removing a user from a site’s members group when they’re already gone throws a ServerException, and treating that as failure would wedge every cleanup retry: catch (ServerException ex) { logger.LogWarning(ex, "Could not remove {LoginName} from {SiteUrl} ({ErrorType}); treating as already removed.", loginName, siteUrl, ex.ServerErrorTypeName); return; } Desired state: user is not in the group. User is not in the group. Done. Idempotency is about converging on a state, not performing an action. Gotchas One non-idempotent activity poisons the whole pipeline. I had a security activity that created SharePoint groups without checking for existing ones. Every retry of that step risked duplicate groups, which meant retries weren’t safe, which meant the retry policy comment was a lie for the whole orchestration. It’s disabled until it earns its way back in. Idempotency is all-or-nothing per pipeline. The failure window is between success and checkpoint. The orchestrator records completed steps, but a crash can land after the activity finished and before the record was written. The completed-steps list narrows how often reruns happen; idempotency is what makes the remaining reruns harmless. You need both. catch the narrowest thing you can. The 409 handler above catches ODataError with status 409, nothing else. A broad catch { return; } also “makes retries pass”, by swallowing real failures. Idempotency guards should be precise enough that a genuinely broken call still throws. Test by running it twice, literally. My smoke test for a new activity is: run the provisioning, then immediately queue the exact same provisioning again. Zero errors and zero duplicates or it doesn’t ship. Wrapping Up A Durable Functions retry policy is a contract: it promises to rerun your activities, and your activities promise not to care. Check before create, read “already exists” as success, prefer upserts, verify writes that lie. Write every activity as if it will run twice, because on a long enough timeline, it will. If retry policies and replay are new to you, Durable Functions: A Function That Sleeps for a Week covers what an orchestration actually is, and why “it will run twice” is a feature rather than a bug. --- ### Sådan laver I en digital opslagstavle i SharePoint Forfatter: Pinksky Dato: 2026-08-04 URL: https://pinksky.dk/blog/2026-08-04-digital-opslagstavle-sharepoint/ Der er kage i kantinen på fredag. En bil holder på parkeringspladsen med lys tændt. Og en fra lageret vil gerne sige tak for hjælpen i mandags. Den slags findes i enhver virksomhed, og de fleste steder ender det enten som en mail til alle, som et opslag i en Teams kanal ingen følger, eller på et stykke papir ved kaffemaskinen, som halvdelen af medarbejderne aldrig går forbi. Det kunne se sådan her ud i stedet: En digital opslagstavle på intranettet samler den slags ét sted. Medarbejderen skriver selv sit opslag, kollegerne kan like og kommentere, og det står der, hvor de i forvejen kigger efter nyheder og genveje. I SharePoint er den ikke et produkt, I skal købe: den er en liste med et par felter og en anden visning, og den kan være på plads på en formiddag uden en udvikler. Nedenfor er de fem trin, der skal til. Hvad er en digital opslagstavle på intranettet? Det er værd at skelne den fra nyhederne, for de to ting løser reelt ikke det samme. Nyheder er redaktionelle og går ovenfra og ned - ledelsen, HR eller kommunikation skriver, og resten læser. Opslagstavlen går den modsatte vej. Den er fra medarbejder til medarbejder, opslagene er korte, de bliver ikke redigeret af nogen, før de kommer op, og de lever kun, så længe de er relevante. Det lyder som en detalje, men det er ofte forskellen på et intranet, der “lever”, og et der ikke gør. Nyheder bliver læst, når der er en nyhed. En opslagstavle giver en grund til at komme forbi i alle de uger, hvor der ikke er nogen. Og fordi opslagene er små og uformelle, er barrieren for at skrive det første langt lavere - man skal ikke skrive en artikel for at fortælle, at Kim har vundet DM i lerdueskydning. Sådan bygger I opslagstavlen Herunder er hele opsætningen. Der er fem trin, og ingen af dem kræver, at nogen udvikler noget. Opret en tom liste. På jeres intranet opretter I en ny, tom liste og giver den et navn, fx “Opslagstavle”. Tilføj de to felter. På den nye liste skal I oprette to felter. Navnene er vigtige: formateringen leder efter dem præcis som herunder, og SharePoint låser et felts interne navn fast ud fra det navn, feltet får ved oprettelsen. I kan roligt omdøbe dem bagefter, men de skal oprettes med de her navne. Felttype Navn Formål Flere tekstlinjer Description Her skriver medarbejderen sit opslag. Sæt feltet til almindelig tekst frem for forbedret rigtekst, ellers ryger formateringskoderne med ud på kortet. Person eller gruppe LikedBy Her lander de kolleger, der trykker like. Feltet skal tillade flere valg. Opret en ny visning. Øverst til højre på listen trykker I på “Tilføj visning” og vælger “Galleri”. Giv visningen et navn, fx “Opslagstavle”, og tryk på “Opret”. Så skal formateringen ind: tryk på visningen øverst til højre, vælg “Formater visning”, tryk på “avanceret tilstand” i den boks, der kommer frem, og indsæt formateringen, I kan hente som en færdig fil. Tryk på “Gem”. Tag afsender og dato med i visningen. Galleri-visningen tager ikke nødvendigvis felterne “Oprettet af” og “Oprettet” med, og uden dem kan kortet hverken vise profilbillede eller tidsstempel. Tryk på visningen igen, vælg “Rediger visning”, og sæt flueben i dem begge under kolonner. Læg listen på intranettet. Nu til den spændende del: at få opslagstavlen ud til medarbejderne. På den side, hvor opslagstavlen skal vises, trykker I på “Rediger” og indsætter webdelen Liste. Vælg jeres liste og den visning, I lige har lavet. Gem siden. Mere kræver det ikke. Nu har I en digital opslagstavle, hvor medarbejderne selv kan skrive opslag, like og kommentere. Hvorfor det er en liste og ikke et produkt Der findes produkter, der kan det samme, og flere af dem er glimrende. Vi har da også selv bygget lignende løsninger med mere komplicerede funktioner, såsom grupper og feeds, men det her illustrerer, hvor langt man reelt kan komme uden at skulle betale en konsulent i dyre timepriser og uden at skulle købe en ny licens. Har I fået blod på tanden og lyst til at se, hvad der ellers kan lade sig gøre med SharePoint lister, findes der et fantastisk community, der deler visningsformateringer og løsninger - fx Microsoft 365 & Power Platform Community - List formatting. Ellers sidder vi hos Pinksky klar til at hjælpe med at bygge videre på jeres intranet på SharePoint, hvis I vil have mere end en opslagstavle. Opslagene bliver samtidig liggende i jeres eget Microsoft 365. Det betyder, at søgningen kan finde dem, at Copilot kan læse dem uden at nogen først skal bygge og vedligeholde en forbindelse til et andet system, og at adgangen følger de brugere og grupper, I allerede administrerer. Skal opslagstavlen kun være for én afdeling, er det en rettighed - ikke en funktion, der skal tilkøbes. Og så kan I ændre den bagefter. Skal opslag have en kategori, skal de forsvinde af sig selv efter 14 dage, skal HR kunne sætte et opslag øverst? Det er et felt mere eller en automatisering i jeres egen liste, ikke et ønske, der skal stilles til en leverandør og vente på et roadmap. Rækker listen ikke, kan den bygges videre som en skræddersyet SharePoint løsning i det samme miljø. Hvad I skal blive enige om, inden I laver den Den tekniske del er den nemme. Det, der afgør, om opslagstavlen stadig lever om et halvt år, står ikke i nogen vejledning. Bliv enige om, hvem der må skrive, og svaret er næsten altid alle - en opslagstavle, der skal godkendes, holder op med at være en opslagstavle. Bliv enige om, hvem eller hvad der rydder op, så den ikke står fuld af sommerfester fra sidste år. Og vigtigst: bliv enige om, hvem der skriver de første ti opslag. En tom opslagstavle er værre end ingen, fordi den bekræfter alle i, at der aldrig sker noget på intranettet. Hos Pinksky bygger vi intranet på SharePoint for danske virksomheder, og opslagstavlen er en af de små ting, der får medarbejderne til at komme tilbage - uden nye licenser og uden at indholdet flytter ud af huset. Vil I hellere bygge den selv, er formateringen ovenfor alt, hvad I skal bruge, og vi hjælper lige så gerne med sparring undervejs som med hele intranettet. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### Hvilken kvalitetsstyringsløsning har I egentlig brug for? Forfatter: Dan Toft Dato: 2026-08-03 URL: https://pinksky.dk/blog/2026-08-03-kvalitetsstyring-loesning/ En kvalitetsstyringsløsning er i praksis fire ting: procedurer og instruktioner der ligger ét sted i den gældende version, en historik der viser hvem der har ændret hvad, godkendelser I kan dokumentere bagefter, og afvigelser der bliver fulgt til dørs frem for at ende i en mailtråd. Det er fire funktioner, ikke et produkt, og det er værd at holde fast i - for de findes allerede i Microsoft platformen, og den betaler langt de fleste danske virksomheder for i forvejen. Vores anbefaling er derfor som regel at begynde der og først købe et system, hvis der er noget, platformen ikke kan. Hvad skal en kvalitetsstyringsløsning kunne? Start med kravet frem for med markedet. Uanset om det er en kunde, en certificering eller jeres egen ledelse, der beder om det, handler kravet næsten altid om det samme: at I kan vise, hvordan arbejdet skal udføres, at beskrivelsen er den gældende, og at I kan dokumentere, at den er blevet godkendt af nogen med mandat til det. Standarderne selv - ISO 9001 er den, de fleste møder først - taler om “dokumenteret information” og stiller krav til, at den er styret. De siger intet om, hvilket program I skal bruge. Den sidste af de fire, afvigelserne, er den, der oftest driller. En afvigelse skal registreres, have en ansvarlig, en frist og en afslutning, og det er sjældent registreringen, der er problemet. Det er opfølgningen. En afvigelse, der lever i en mailtråd mellem to personer, er reelt ikke registreret, uanset hvor grundigt den er beskrevet. Når kvalitetsstyring bliver oplevet som bureaukrati, er det næsten altid, fordi den del mangler: alle kan mærke, at der bliver skrevet noget ned, og ingen kan se, at der sker noget bagefter. Hvornår rækker jeres Microsoft 365? Oftere end de fleste regner med, fordi de fire funktioner er standardfunktioner. En simpel kvalitetsstyring kræver derfor sjældent, at der bliver købt noget nyt. Et dokumentbibliotek i SharePoint har versionsstyring slået til fra start, så der altid er én gældende version, og hele historikken ligger bagved. Godkendelser bygges med Power Automate, som er inkluderet i de fleste Microsoft 365 abonnementer: den ansvarlige får anmodningen i Outlook eller Teams, svarer derfra, og svaret bliver liggende som dokumentation. Afvigelser registreres i en formular og lander på en liste med ansvarlig og frist, og de påmindelser, der får en afvigelse lukket igen, er den samme slags automatik. Det er også her, det får betydning, at kvalitetsarbejdet sjældent foregår ved et skrivebord. Proceduren skal kunne slås op i produktionen, på lageret eller i bilen, og det forudsætter, at medarbejderen har en licens. Har de ikke det i dag, er det værd at regne på Microsofts frontlinjelicenser, inden I konkluderer noget: de er lavet til præcis den situation og ligger et godt stykke under en almindelig licens. En kvalitetsstyringsløsning bygget på Microsoft 365 betyder samtidig, at dokumentationen bliver liggende i jeres eget miljø, omfattet af de adgange og den sikkerhed, I allerede har sat op - og at Microsoft 365 Copilot kan læse med i procedurerne, uden at der først skal bygges en forbindelse til et fremmed system. Hvornår er et kvalitetsstyringssystem det rigtige valg? Der findes tilfælde, hvor svaret er et produkt, og vi siger det gerne, når det er. Arbejder I i en branche med meget detaljerede krav - medicinsk udstyr eller fødevarer, for eksempel - kan der ligge reel værdi i et system, hvor leverandøren vedligeholder skabelonerne op mod standarden og opdaterer dem, når den ændrer sig. Det er arbejde, I ellers selv skal holde styr på. Og har I hverken en intern IT funktion eller nogen ambition om at bygge videre på jeres platform, er en færdig æske ofte det rigtige valg, netop fordi den kommer med rammer, nogen andre har tænkt igennem. Læg mærke til, hvad I faktisk køber i den situation. Det er ikke funktionerne - dem har I allerede - det er fortolkningen af kravene og en løbende sikkerhed for, at skabelonerne følger med, når standarden bliver revideret. Har I selv den faglighed, altså ved I, hvad reglerne kræver af jer, og hvad der skal til for at leve op til dem, så falder den begrundelse væk. Så kan et branchespecifikt krav lige så godt bygges på Microsoft 365, formet efter den måde I rent faktisk arbejder på, frem for efter en skabelon, der skal passe til alle i branchen. Vi har bygget den slags til en række danske virksomheder med hver deres fagkrav, og det er sjældent funktionerne, der er det svære - det er at blive enige om, hvordan arbejdet skal beskrives. Et produkt er til gengæld undtagelsen frem for udgangspunktet, og det er værd at regne igennem frem for at antage. To spørgsmål afgør det som regel: hvad gør produktet, som jeres platform ikke gør, og hvor kommer jeres dokumenter fysisk til at ligge? Får I ikke et klart svar på det sidste, er det i sig selv et svar, for placeringen bestemmer, hvad I har tilbage den dag, abonnementet stopper. Sidder I med et konkret tilbud, kigger vi det gerne igennem som en uvildig vurdering - også hvis konklusionen er, at tilbuddet er fornuftigt som det er. Hvad koster en kvalitetsstyringsløsning? Bygger I på Microsoft 365, er der typisk ingen nye licenser oveni dem, I allerede betaler, og investeringen ligger i opsætningen: strukturen på dokumenterne, godkendelsesflowet og oplæringen af dem, der skal holde det ved lige. Et standardsystem koster derudover en løbende licens pr. medarbejder, og den udgift løber, uanset hvor stor en del af systemet I ender med at bruge. Det er derfor, en kvalitetsstyringsløsning på jeres egen platform sjældent ser billigst ud i uge et, men typisk er den billigste vej på den lange bane. Den største omkostning står der til gengæld ikke noget om i noget tilbud. Det er den, I betaler, hvis kvalitetsstyringen dør: procedurerne bliver ikke opdateret, medarbejderne holder op med at slå dem op og arbejder efter den udgave, der hænger på væggen, og afvigelserne bliver registreret, fordi de skal, ikke fordi nogen følger op. Så er det i praksis lige meget, hvilket system I valgte - I har betalt for dokumentation, ingen bruger. Spørgsmålet om, hvem der skriver procedurerne, og hvornår de bliver gennemgået næste gang, er derfor mindst lige så vigtigt som spørgsmålet om prisen. Start med det, I allerede ejer Anbefalingen er den samme, som når vi bygger kontraktstyring på Microsoft 365: byg videre på jeres egen Microsoft platform, og køb kun det, den ikke kan. Så bliver dokumentationen liggende hos jer, adgangen følger de brugere og grupper, I i forvejen administrerer, og det næste ønske på listen kan I selv sige ja til frem for at vente på, at en leverandør prioriterer det. Hvor meget af arbejdet I selv laver, bestemmer I også selv. Har I folk til det internt, kan I sagtens bygge det i huset og bruge os til sparring undervejs - et kig på dokumentstrukturen, inden den bliver til 100 biblioteker, eller hjælp til godkendelses- og afvigelsesflowet, som er den del, der oftest kræver et par hænder med erfaring. Og skal vi tage hele opgaven, både beslutningen og implementeringen, gør vi det lige så gerne. Hos Pinksky bygger vi kvalitetsstyring på Microsoft 365 for danske produktions- og servicevirksomheder - uden nye licenser, og med det mål at I selv kan holde løsningen i live bagefter. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### CSOM vs SharePoint REST vs Graph: My Pick-One Playbook Forfatter: Jeppe Spanggaard Dato: 2026-07-30 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-07-csom-vs-sharepoint-rest-vs-graph/ You start a SharePoint feature the sensible way. Graph first, because it’s the modern API and because that’s what PnP Core does by default. You write the upload. You write the metadata. You write the listing code. It works. Then you hit the one field, the one property, the one checkbox Graph doesn’t do. And you hit it on day three, with the feature already shaped around Graph. Sometimes rewiring it is cheap. Sometimes it’s a rewrite. Either way you paid. I’ve walked into that wall enough times now that I stopped calling it bad luck and started writing down which tool I reach for when. This post is that list. The 200 That Meant Nothing The worst one was metadata on uploaded documents. My add-in uploads a file with Graph, then applies the library’s columns to the resulting list item with a PATCH to /listItem/fields. Text columns, choice columns, dates, all good. Then a managed metadata column. 200 OK. Empty field. Not a 400. Not “column type not supported”. Graph accepted the payload, answered like everything was fine, and wrote nothing. The Learn page for updating a listItem shows you Color and Quantity and never mentions which column types don’t make it through. A thrown error costs you an hour. A silent success costs you until someone notices the taxonomy column is blank on documents that were archived weeks ago, and then it costs you a data migration. That’s the real argument for having a playbook. Not elegance. The cases where the wrong choice fails quietly. Be careful about which columns you write off, though. I had multi-value lookups and person columns on my “Graph can’t” list for a long time, and both were wrong. They work fine, they just don’t take the shape you’d guess coming from CSOM, and that’s its own post. Managed metadata is the one that genuinely has no Graph write path. Graph First Is a Default, Not a Plan To be clear, “Graph first” is a good instinct. PnP Core builds it in: the SDK favours Graph when reading SharePoint data and falls back to SharePoint REST when the requested properties aren’t available there, and you can flip GraphFirst off if you disagree. I’ve written before about why I’m still on PnP.Framework, and that per-operation routing is the thing I most want from PnP Core. Until I have it, I’m the router. So here’s how I route. CSOM Owns List Items Roughly 95% of my list item CRUD is CSOM, and it’s not nostalgia. Three capabilities keep it there. Batching without a hard ceiling. CSOM queues operations until you call ExecuteQueryAsync(), and around 100 operations per batch is the reliable sweet spot. Graph’s $batch caps at 20 requests, and a failed batch needs picking apart before you retry it. When I’m updating 500 items, that difference is 5 round trips versus 25. CAML joins. One query across lists connected by lookup columns, filtered on a field two lists away, merged server-side. There’s no Graph equivalent, and I’ve never found a way to fake it that didn’t end in merging rows in C#. A way past the list view threshold. Query a big list and CSOM throws The attempted operation is prohibited because it exceeds the list view threshold. There’s a flag on CamlQuery that gets you through it, combined with paging and indexed columns. That one deserves its own post and it’s on my list. The rest of my CSOM reasoning is in the CSOM performance playbook, and the join pattern has its own post. Graph Owns Files For files it flips completely. Uploading and downloading through Graph is fewer requests and noticeably faster in every project I’ve measured it in, and large files are the clearest case: createUploadSession gives you a resumable, chunked upload with a pre-authenticated URL, and the chunks don’t carry an Authorization header at all. // Small files: straight PUT to the content endpoint if (file.size <= SIMPLE_UPLOAD_LIMIT) { return folder.concat(`:/${filename}:/content`).put(file); } // Large files: a session, then sequential chunks const session = await folder.createUploadSession({ name: filename }); for (const chunk of chunks) { await session.resumableUpload.upload(chunk.length, chunk, contentRange); } The rules that matter: chunk sizes must be a multiple of 320 KiB, they go up sequentially, and each PUT extends the session expiry. Get the multiple wrong and the upload fails at the last chunk, which is a fun way to spend an afternoon. Downloads get the same treatment - zipping a whole folder out of SharePoint is a Graph job for me, not a CSOM one. Graph’s JSON batching maps cleanly onto “fetch these 20 files’ content”, though matching responses back to requests has its own quirks that I covered in Graph batching for file content. SharePoint REST Is the Escape Hatch I almost never choose SharePoint REST. I end up there when it’s the only thing that works, which turns out to be more often than the modern-API story suggests. From my current projects: Setting a navigation link to open in a new tab. It’s a checkbox in the UI, it’s not in the PnP provisioning schema, and it’s not on CSOM’s NavigationNode. It’s MenuState/SaveMenuState. I have a whole post coming about that one. Reading the sites a user follows: /_api/social.following/my/followed(types=4). Joining a hub site, activating site features, applying a site design, adding an available content type to a library. All _api calls in my provisioning engine. ValidateUpdateListItem, which is what rescues the metadata story from the top of this post. And /_api/web/ensureuser, because Graph has no EnsureUser and no /sites/{id}/users endpoint at all. The pattern is consistent: the older and more SharePoint-specific the concept, the more likely REST is the only place it lives. ValidateUpdateListItem is worth knowing by name. It takes form values in SharePoint’s own wire format - taxonomy as Label|GUID, people as a JSON array of claim keys - and it’s the same payload the classic edit form posts, which is exactly why it accepts the column types Graph won’t touch. In a C# backend it’s a method on ListItem in the client library; in a browser add-in with no CSOM available, it’s the REST endpoint. Either way, read the response: rejected fields come back with HasException: true inside an otherwise successful call, so you can recreate the silent-200 problem from the other direction if you don’t look. The EnsureUser gap is the more interesting one, because it’s what stops person columns from being a pure Graph story. The ids in ReviewersLookupId are site-collection user ids from the User Information List. Not Entra object ids, not Graph user ids. If a user has never been referenced on that site, they simply have no id, and Graph gives you no way to create one. So you POST /_api/web/ensureuser with a logonName first, then write the item with Graph. Or you stay in CSOM and let Web.EnsureUser() plus a FieldUserValue do both in one place, which is what I usually do in a backend. One nice asymmetry on the taxonomy side: reading the term store works fine over Graph (/sites/{id}/termStore/sets/{id}, with TermStore.Read.All). It’s only writing a term onto a list item that Graph won’t do. So my term picker is Graph and my term save is not. Gotchas A 200 is not proof. This is the whole post in one bullet. Graph accepts unsupported column types and writes nothing. Assert on the value you wrote, at least once per column type, in a real library. Otherwise you find out at migration time. Mixing APIs means mixing tokens. A Graph token does not work against /_api. SharePoint REST wants an audience of https://.sharepoint.com/.default, so a flow that uses both acquires two tokens and your app registration needs both sets of permissions. Plan the consent, not just the code. Lookups are FieldNameLookupId, not FieldName. Graph names the writable property differently from the column. Writing to the column name silently does nothing. Yes, silently. Switching to ValidateUpdateListItem needs the list item id. Drive item ids don’t work, so $select=sharepointIds on the Graph item first and use sharepointIds.listItemId. It’s logonName, not loginName. The ensureuser parameter is spelled the unintuitive way, some docs get it wrong, and the wrong spelling gets you an InvalidClientQueryException that says nothing useful. Chunk sizes are a multiple of 320 KiB or nothing. Graph upload sessions fail on the final commit, not on the offending chunk, so the error points at the wrong place. “Unsupported” is sometimes just undocumented. Multi-value lookups and person columns sat on my can’t-do list for far too long. Before you route an operation to the older API, check whether the modern one only lacks a doc page. Wrapping Up There’s no winner here. CSOM, SharePoint REST and Graph are three drawers in the same toolbox, and picking per operation beats picking per project. My rule of thumb: Graph for files, CSOM for list items, SharePoint REST when nothing else can do it - and verify the write whenever you cross a boundary. The failure mode that actually hurt me wasn’t choosing the slower API. It was choosing the API that said yes and did nothing. --- ### Hvilken intranet løsning passer til jeres virksomhed? Forfatter: Dan Toft Dato: 2026-07-28 URL: https://pinksky.dk/blog/2026-07-28-hvilken-intranet-loesning/ Der er mange måder at købe et intranet på, og i dette indlæg kigger vi nærmere på de tre mest almindelige. I kan købe en selvstændig platform, der står ved siden af jeres Microsoft 365 med sit eget login. I kan købe et “intranet in a box”, hvor der ligger et produktlag oven på jeres SharePoint. Eller I kan bygge intranettet i jeres egen SharePoint, standard først, og bygge de få ting oveni, som Microsoft ikke selv laver. Vi anbefaler den tredje vej, og vi gør det også, når medarbejderne ikke har en Microsoft licens i dag - for den kan de få billigt, og så bygger I videre på en platform, I allerede ejer, frem for at tage endnu et system ind. Hvad er de tre slags intranet løsninger? Alle tre ender med et intranet, medarbejderne kan bruge fra første dag, og derfor kan de tre også godt ligne hinanden i et salgsmøde. Forskellen viser sig bagefter: når HR beder om noget, der ikke findes, når de 200 medarbejdere i produktionen skal med, og når regningen for år to skal godkendes. Skillelinjen er derfor ikke, hvad de kan vise på en forside. Den er tre ting: hvor indholdet ligger, hvem der kan komme til det, og hvem der bestemmer, hvad løsningen kan om to år. Den selvstændige platform: eget login, egne licenser, indholdet hos leverandøren Den første vej er et produkt, der står helt for sig selv, med sin egen app, sin egen brugerdatabase og sit eget login, og indholdet ligger i leverandørens system. Der findes flere dygtige danske produkter i den kategori, og deres styrke bliver ofte overset af os, der lever på Microsoft platformen: en selvstændig platform kan nå alle medarbejdere, uanset om de har en Microsoft licens eller ej. I en produktionsvirksomhed, hvor kontoret har Microsoft 365 og folkene i hallen har et lønnummer og deres egen telefon, er det hele forskellen på, om intranettet findes for tre fjerdedele af virksomheden eller for alle. Dertil er de produkter typisk tænkt app først, med en betjening der er lavet til en telefon i en arbejdshandske frem for til en musemarkør. Prisen er, at I får endnu et system at eje. Endnu et login medarbejderne skal huske, endnu et rettighedsopsæt der skal gennemgås, når nogen skifter afdeling eller siger op, og endnu et sted jeres data ligger. Og intranettet står ved siden af det, arbejdet foregår i: dokumenterne, samtalerne i Teams og søgningen er et andet sted, så medarbejderen har fået et sted mere at kigge frem for et sted mindre. Intranet in a box: et produktlag oven på jeres SharePoint Den anden vej ligner den første i et salgsmøde, men er noget helt andet. Der ligger et produktlag oven på jeres SharePoint, og det overtager i praksis SharePoint: forsiden, navigationen og redaktørens værktøjer er leverandørens, mens platformen nedenunder er jeres. Det ser typisk rigtig lækkert ud, og det gør det med god grund - nogen har brugt lang tid på designet, rammerne er faste, og faste rammer er ofte netop det, der forhindrer et intranet i at skride år efter år. Til gengæld betaler I to gange: for Microsoft 365, som I har i forvejen, og oveni en løbende licens pr. medarbejder for et lag, der ligger på noget, I allerede ejer. Og et produkt bygget på SharePoint spiller ikke automatisk godt sammen med resten af Microsoft platformen, bare fordi der står SharePoint på begge. Det vigtigste spørgsmål er samtidig det, der sjældent bliver stillet: hvor lever jeres data henne, når produktet er sat op? Nogle produkter lægger nyheder og sider i jeres eget SharePoint som helt almindeligt indhold, og så bliver jeres data liggende hos jer. Andre bruger kun SharePoint som ramme og lader indholdet leve i leverandørens egen database ved siden af - og så er I i praksis tættere på en selvstændig platform end på jeres egen SharePoint, uanset hvad der stod i tilbuddet. Det afgør, hvad søgningen og Copilot kan finde, og hvad I har tilbage den dag, I stopper abonnementet. Bed derfor leverandøren pege konkret på, hvor en nyhed ligger, frem for at gå ud fra at “det ligger i SharePoint” betyder det, I tror. Egen SharePoint, standard først: platformen krydret med det, Microsoft ikke laver Den tredje vej er den kedelige, og det er ment som ros. Intranettet bygges i SharePoint, som platformen er: nyheder, kommunikationssites, navigation, målgruppestyring og søgning er standardfunktioner, konfigureret til jeres organisation. Der er ikke et produktlag ovenpå, som nogen skal blive ved med at understøtte, og ingen løbende licens pr. medarbejder oveni den, I allerede betaler for. Og så er der de få ting, standardplatformen ikke laver. Tag noget så jordnært som en fødselsdagsliste: skal den vise afdelingen, skal runde fødselsdage fremhæves, skal datoerne komme fra HR systemet? Der findes ingen leverandør, der har lavet den præcis, som jeres organisation gerne vil have den, og den er samtidig alt for lille til nogensinde at komme øverst på et produkt-roadmap. Standard først betyder derfor ikke standard kun: den manglende funktion kan bygges som en skræddersyet SharePoint løsning og ligger i jeres eget miljø ligesom resten. Pointen er ikke, at alting skal specialbygges - langt de fleste behov løses med den platform, I allerede har, og vi anbefaler konsekvent at starte der. Pointen er, at der findes et loft de to andre steder og ikke her. Til gengæld får I ikke en færdig æske. Der skal træffes beslutninger om struktur, design og redaktørroller, og nogen internt skal eje opsætningen bagefter. Et intranet bygget på jeres egen SharePoint kan sagtens sættes op med stramme redaktørrammer, men rammerne bliver sat af jer frem for af et produkt. Hvor meget af det arbejde I selv laver, bestemmer I til gengæld også selv. Har I folk til det internt, kan I sagtens bygge intranettet i huset og bruge os til sparring undervejs - et kig på informationsarkitekturen, inden den bliver til hundrede sites, eller hjælp til den ene funktion, platformen ikke selv laver. Og skal vi tage hele opgaven, gør vi det lige så gerne: både beslutningen om, hvilken vej I skal gå, og implementeringen bagefter. Hvad gør I med de medarbejdere, der ikke har en Microsoft licens? Det er det spørgsmål, der oftest afgør valget i praksis, og det bliver næsten altid stillet for sent. Et SharePoint intranet kræver, at medarbejderen har en licens, så har halvdelen af jeres medarbejdere ikke en, ser regnestykket umiddelbart ud til at falde ud til den selvstændige platform. Men det er værd at regne på F-licenserne først. Microsofts frontlinjelicenser er lavet til præcis den situation: medarbejdere der arbejder fra en telefon eller en tablet, ofte på skiftehold, og som skal kunne finde nyheder, procedurer og oplæring uden at have brug for en fuld kontorpakke. De ligger et godt stykke under en almindelig licens i pris og findes i flere niveauer, hvor det billigste er tænkt til at læse med. Så er sammenligningen ikke længere “intranet eller ikke intranet”, men en frontlinjelicens pr. medarbejder mod en produktlicens pr. medarbejder. Og den falder for de fleste ud til frontlinjelicensen - ikke kun på prisen, men fordi medarbejderen samtidig får en firmaidentitet, Teams og styret dokumentadgang med i købet, og fordi I ender med ét sted at administrere brugere frem for to. Manglende licenser er altså sjældent en grund til at vælge et produkt. Det er en grund til at få licenserne regnet igennem, og det er en af de ting, vi oftest ender med at regne på i en uvildig rådgivning. Bliver intranettet brugt, når medarbejderen ikke sidder ved en computer? Der er en antagelse indbygget i mange intranetprojekter: at læseren sidder ved et skrivebord. For en stor del af danske virksomheder er det højst halvdelen af sandheden. Chaufføren, produktionsmedarbejderen og butiksassistenten har også brug for at vide, at kantinen holder lukket på fredag. Et SharePoint intranet er mobilvenligt i sig selv - siderne tilpasser sig skærmen, også når forsiden er bygget i tre kolonner til en stor skærm - så det er ikke der, problemet ligger. Det ligger i, at ingen åbner et intranet af sig selv midt i en arbejdsdag. Det er derfor SharePoint appen i Teams, som tidligere hed Viva Connections, gør en større forskel end de fleste regner med. Den viser jeres intranet inde i den app, medarbejderen alligevel har på telefonen, og den gør det som et dashboard frem for som en forside: kort med de få ting, rollen rent faktisk skal bruge - vagtplanen, sygemeldingen, oplæringen - og nyhederne samlet ét sted. Vigtige beskeder kan sendes, så de lander som en notifikation på telefonens låseskærm, og så er det ikke længere op til den enkelte at huske at kigge forbi. Ikke en app mere at installere, ikke et login mere at huske, og ikke en separat mobiludgave, der skal vedligeholdes ved siden af den rigtige. Kan Copilot bruge indholdet på jeres intranet? Svaret hænger direkte sammen med, hvor indholdet ligger. Microsoft 365 Copilot arbejder på det indhold, den enkelte medarbejder har adgang til i jeres eget Microsoft 365, så ligger intranettets nyheder, sider og procedurer i SharePoint, er de med fra dag et, uden at der skal bygges noget. Ligger de i en selvstændig platform, er de ikke med - det kan kobles på, men det er en integration, der skal etableres, vedligeholdes og betales for. Og et intranet in a box ligger midt imellem, af samme grund som ovenfor: det, produktet lægger i SharePoint, er med, mens det, produktet holder i sin egen datamodel, ikke nødvendigvis er. Bed leverandøren vise, hvad Copilot rent faktisk kan svare på ud af intranettet, og læs mere om forudsætningerne under AI og Microsoft Copilot. Hvad koster en intranet løsning? Prisen på et tilbud og prisen på en løsning er to forskellige tal. De to første veje koster typisk en løbende licens pr. medarbejder oveni opsætningen, og den udgift løber, uanset hvor mange af funktionerne I ender med at bruge. Bygger I i jeres egen SharePoint, har I allerede betalt for platformen som en del af jeres Microsoft 365 abonnement, og investeringen ligger i stedet i informationsarkitekturen, designet og oplæringen af redaktørerne. Det er derfor, standard først typisk er den billigste vej på den lange bane, mens den sjældent ser billigst ud på et tilbud i uge et. Den største omkostning står der til gengæld ikke noget om i noget tilbud. Det er den, I betaler, hvis intranettet dør: forsiden står med den samme nyhed fra marts, ingen har fået ansvaret for at holde det ved lige, og medarbejderne holder op med at kigge. Så er det i praksis lige meget, hvilken af de tre veje I valgte, og næsten lige meget om I har et intranet eller ej - I har betalt for noget, ingen bruger. Intranets dør sjældent af manglende funktioner, de dør af manglende redaktører, så spørgsmålet om hvem der skriver, og hvornår, er mindst lige så vigtigt som spørgsmålet om prisen. Vi har skrevet mere om, hvordan et intranet bygges med standard SharePoint. Og sidder I allerede med et konkret tilbud, kigger vi det gerne igennem med jer som en uvildig second opinion - og siger ærligt, hvad vi ville gøre, også hvis konklusionen er, at tilbuddet er fint som det er. Byg på den platform, I allerede ejer Alle tre veje ender med et intranet, men de ender ikke med det samme ejerskab. Bygger I i jeres egen SharePoint, ligger indholdet hos jer, adgangen følger de identiteter I i forvejen administrerer, Copilot kan læse med uden at der skal bygges en forbindelse, og det næste ønske på listen kan I sige ja til selv. Det er også den vej, hvor I ikke er afhængige af, at en tredjepart bliver ved med at prioritere det, jeres organisation har brug for, og hvor der ikke løber en licens pr. medarbejder oveni den, I allerede betaler. Og den vej er åben, også når medarbejderne ikke har licenser i dag. Det er hele pointen med F-licenserne: de er billige nok til, at de sjældent er det, der vælter regnestykket, og så har I bygget noget på en etableret platform, I selv ejer og selv kan holde i live - frem for at tage endnu et system, endnu et login og endnu en leverandør ind i huset for at nå de samme medarbejdere. De to andre veje har deres plads, og vi siger det gerne, når de er svaret. Et produkt er det rigtige valg, hvis I hverken har en intern IT funktion eller nogen ambition om at bygge videre på platformen. Og en selvstændig platform kan stadig være svaret, hvis medarbejderne aldrig skal have en identitet hos jer - meget korte ansættelser, sæsonarbejde eller eksterne, der kommer og går. Men det er undtagelsen frem for udgangspunktet, og det er værd at regne den igennem frem for at antage den, for det, der gør beslutningen forkert, er sjældent produktet. Det er at træffe den på en featureliste og først bagefter opdage, hvad der lå udenfor den. Hos Pinksky bygger vi intranet i jeres egen SharePoint for alt fra små organisationer til virksomheder med flere tusinde medarbejdere - uden nye licenser pr. medarbejder, og med det mål at I selv kan holde det i live bagefter. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### PnP Core vs PnP.Framework: Why I Haven't Switched Yet Forfatter: Jeppe Spanggaard Dato: 2026-07-27 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-07-pnp-core-vs-pnp-framework-migration-blockers/ I’ve been building an internal shared library. The idea is boring and useful: collect the code I write over and over into one place, keep it as up to date as I can, and make it battle tested rather than “worked on my tenant last Tuesday”. Testing with Dev Proxy instead of hope - I’ve simulated throttling with it enough now to trust what it tells me. That kind of library is exactly the moment to pick your foundation on purpose. So I sat down and looked hard at PnP Core. And then I stayed on PnP.Framework. What Makes PnP Core Tempting The thing I actually want from PnP Core isn’t a feature. It’s that somebody already made a decision I keep having to make myself. With CSOM and raw calls, every time I read something out of SharePoint I have to think about how: SharePoint REST, or Microsoft Graph? Which one has this property? Which one is faster here? PnP Core removes that question. Per the docs, the SDK “by default is configured to favor the Microsoft Graph API when you’re reading SharePoint data assuming the requested properties are available via Graph”, and it falls back to SharePoint REST when they aren’t. If you disagree, you flip GraphFirst to false on the PnPContext and it prefers REST instead. Someone with more context than me picked the better call per operation, and I get to write the intent instead of the plumbing. Add async-first, a real domain model, and modern .NET hosting, and it’s not a close comparison on paper. The problem is that my code doesn’t run on paper. Blocker 1: The Provisioning Engine Isn’t There Almost everything I build for SharePoint eventually provisions something. Lists, content types, pages, navigation. That’s ApplyProvisioningTemplate, and it lives in PnP.Framework. PnP Core has no provisioning engine. So a migration wasn’t “rewrite my queries”. It was “rewrite my queries and keep PnP.Framework around anyway for the part that does the heavy lifting”. Two SDKs, two mental models, no win. That’s the part I’d already made peace with, until pnpframework issue #1237 showed up in June: a roadmap for moving both the Provisioning Engine and the Modernization Engine into PnP Core as dedicated projects, with the PnP.Framework versions deprecated afterwards. Target is Q4 2026. That single issue turns my biggest objection into a waiting game. I don’t need PnP Core to grow a provisioning engine as a favor to me. It’s on the roadmap, in the open, with a phase 2 already referenced for the wider PnP.Framework deprecation. Everything I’ve written about splitting templates into modular files for retry resilience survives that move, because it’s a property of how you structure templates, not of which SDK applies them. Blocker 2: CAML Joins Come Back Empty The second one I couldn’t wait out, because it’s the query pattern I lean on hardest. SharePoint’s CAML supports joining lists and pulling fields across the join with and . It’s the difference between one query and four, and I’ve written a whole post on doing it with CAMLEX in CSOM because it saves resource units and spares you from merging rows in C#. Run that same view XML through PnP Core’s LoadItemsByCamlQueryAsync and nothing breaks. No exception, no error, no warning. You get items back, the join is honored for filtering, and the projected fields simply aren’t in the result. The REST GetItems endpoint doesn’t serialize them, so they never make it into the response for the SDK to map. A silent hole in the data is worse than a thrown exception, and it’s a hole in the one query shape I most wanted to keep. So I filed issue #1799 and then PR #1802 with an implementation: two new methods on IList that run the query through CSOM’s List.GetItems(CamlQuery) instead of REST, because CSOM does return projected fields. /// /// Loads list items based up on a CAML query executed via CSOM, which also returns /// fields projected from a joined list (CAML Joins/ProjectedFields) /// public Task LoadItemsByCamlQueryViaCsomAsync(CamlQueryOptions queryOptions); public ICamlQueryCsomResult LoadItemsByCamlQueryViaCsom(CamlQueryOptions queryOptions); Using it looks like any other PnP Core call. This is the shape from the integration test in the PR: var page1 = await list.LoadItemsByCamlQueryViaCsomAsync(new CamlQueryOptions() { ViewXml = viewXml, DatesInUtc = true }); var firstItem = page1.Items[0]; var projected = firstItem["ProjectedText"] as IFieldLookupValue; Console.WriteLine(projected.LookupValue); // Next page, without hand-building a paging string var page2 = await list.LoadItemsByCamlQueryViaCsomAsync(new CamlQueryOptions() { ViewXml = viewXml, DatesInUtc = true, PagingInfo = page1.PagingInfo }); What’s happening here? LoadItemsByCamlQueryViaCsomAsync takes the exact same CamlQueryOptions as the REST-based method, so the view XML with and is unchanged. Only the transport differs. The projected field comes back typed as an IFieldLookupValue, not as a raw string. That’s how CSOM represents it and it’s what you’d expect from a lookup coming across a join, so LookupId and LookupValue are both there. The returned ICamlQueryCsomResult carries Items plus PagingInfo, taken from CSOM’s ListItemCollectionPosition. Feed PagingInfo back into the next call and you get the next page. It’s null when there are no more pages. The loaded items are also merged into the list’s Items collection, the same as the existing CAML methods, so nothing about the surrounding model changes. Gotchas The failure mode is silence, not an error. No exception on a join query with projected fields means your code happily maps a null and moves on. If you’re evaluating PnP Core for join-heavy work, assert on the projected value in a test, don’t eyeball the item count. A roadmap is a plan, not a shipped release. Issue #1237 has a Q4 2026 target and it’s still open. Same for my PR: open, no reviewer assigned yet. I’m building on the current state of both SDKs, not on the version I hope exists in six months. No batch variant for the CSOM path, on purpose. The items are materialized immediately, so it doesn’t fit PnP Core’s batching model. If you’re used to queueing everything into a batch, this one call stands apart. Deprecation warnings are part of the plan. Once the engines land in PnP Core, the PnP.Framework equivalents get marked deprecated and stop taking new features. So if you’re searching for whether PnP.Framework is deprecated and whether the provisioning engine is moving to PnP Core: yes and yes, on a published roadmap, and the two blockers above are what stand between “announced” and “usable”. Staying put is a decision with an expiry date, which is exactly why I’d rather have the blockers resolved than keep postponing. Wrapping Up I’m not avoiding PnP Core because I prefer PnP.Framework. I’m on PnP.Framework because two specific things weren’t there: the provisioning engine, and CAML joins that actually return the joined data. Both now have an issue number attached, and one of them has my name on the pull request. Rule of thumb: pick the SDK by what your workload actually needs today, then go make the gap smaller instead of waiting for someone else to. Filing #1799 took an evening. It moved my migration date more than another month of reading release notes would have. When the provisioning engine lands in PnP Core and projected fields come back from a join, I’m switching and I’m not looking back. --- ### F12 Shows Your API Key: Proxy It Behind an Azure Function Forfatter: Jeppe Spanggaard Dato: 2026-07-25 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-07-spfx-azure-function-api-proxy-hide-token/ I was building an SPFx web part that shows data from a third-party system - one of those practice-management/CRM-style products with a REST API. The API authenticates the simple way: one tenant-wide key in an X-Api-Key header. Our key, for all of our data. First instinct: call the API straight from the web part. It works in twenty minutes. Then you press F12, open the network tab, click any request… and there it is. The company’s master API key, in plain text, readable by every single user who ever loads that intranet page. And it’s not “they can see the data the web part shows anyway” - the web part shows a filtered slice. The key unlocks the whole API: every customer, every record, every write operation the key is licensed for. Anyone who copies it out of the inspector can call the API from Postman on their couch. Why the Browser Can’t Keep a Secret It’s worth being blunt about this, because the temptation to “just obfuscate it a bit” is real: Everything the browser sends is in the network tab. Everything the bundle contains is in the sources tab (source maps or not, strings are strings). Everything the app holds in memory is one breakpoint away. There is no hiding place in the client. And “it’s only our internal SharePoint” doesn’t help - internal users are exactly the people who shouldn’t be walking around with the master key to a system they’re only supposed to see a corner of. The Proxy Function The fix is a small reverse proxy. One catch-all Azure Function fronts the entire third-party API: private static readonly HashSet _hopByHop = new(StringComparer.OrdinalIgnoreCase) { "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "TE", "Trailer", "Transfer-Encoding", "Upgrade" }; [Function("ApiProxy")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", "put", "delete", Route = "api/{*path}")] HttpRequest request, string? path, CancellationToken ct) { var targetUri = _settings.BaseUrl.TrimEnd('/') + "/" + (path ?? "") + (request.QueryString.Value ?? ""); using var upstream = new HttpRequestMessage(new HttpMethod(request.Method), targetUri); if (request.Method is "POST" or "PUT" || request.ContentLength is > 0) { upstream.Content = new StreamContent(request.Body); if (request.ContentType is not null) upstream.Content.Headers.TryAddWithoutValidation("Content-Type", request.ContentType); } foreach (var (key, values) in request.Headers) { // Never forward the client's Host, any API key they try to sneak in, // or hop-by-hop headers that belong to *this* connection only. if (key.Equals("Host", StringComparison.OrdinalIgnoreCase) || key.Equals("X-Api-Key", StringComparison.OrdinalIgnoreCase) || _hopByHop.Contains(key)) continue; if (!upstream.Headers.TryAddWithoutValidation(key, (IEnumerable)values!)) upstream.Content?.Headers.TryAddWithoutValidation(key, (IEnumerable)values!); } // The swap. The secret exists only here, on the outbound leg. upstream.Headers.Add("X-Api-Key", _settings.ApiKey); var client = _httpClientFactory.CreateClient("upstream-api"); using var response = await client.SendAsync(upstream, HttpCompletionOption.ResponseHeadersRead, ct); _logger.LogInformation("Proxy: {Method} {Path} → {StatusCode}", request.Method, path, (int)response.StatusCode); var httpResponse = request.HttpContext.Response; httpResponse.StatusCode = (int)response.StatusCode; foreach (var (key, values) in response.Headers.Concat(response.Content.Headers)) { if (_hopByHop.Contains(key)) continue; httpResponse.Headers.Append(key, values.ToArray()); } await response.Content.CopyToAsync(httpResponse.Body, ct); return new EmptyResult(); } What’s happening here? Route = "api/{*path}" is a catch-all. GET /api/customers/123/tasks becomes GET https://the-api.example/customers/123/tasks, query string included. One function fronts the whole API surface - when the vendor adds endpoints, the proxy needs zero changes. The header loop deliberately strips any inbound X-Api-Key. A caller can’t inject their own key or override yours - whatever they send in that header dies at the proxy. The hop-by-hop set (Connection, Transfer-Encoding, …) is the detail naive proxies get wrong. Those headers describe one connection, not the request; forwarding them causes wonderfully confusing breakage. The real key is added to the outbound request only. Response headers get copied back to the browser, but request headers are never echoed - so the key physically cannot appear in the inspector. It’s not hidden; it’s absent. ResponseHeadersRead + CopyToAsync streams the upstream response straight through without buffering it in the function’s memory, and upstream status codes pass through untouched so the web part can react to a 404 or a 429 honestly. Where the Key Lives Server-side config, strongly typed, validated at boot: services.AddOptions() .Bind(configuration.GetSection("UpstreamApi")) .Validate(s => IsConfigured(s.BaseUrl), "UpstreamApi:BaseUrl must be configured.") .Validate(s => IsConfigured(s.ApiKey), "UpstreamApi:ApiKey must be configured.") .ValidateOnStart(); private static bool IsConfigured(string value) => !string.IsNullOrWhiteSpace(value) && !value.StartsWith('<'); The value itself comes from Function App settings - ideally as a Key Vault reference - never from a committed file. ValidateOnStart() plus the guard means a misconfigured secret fails the deployment at boot, not at the first user’s click three days later. Hiding ≠ Authorizing Here’s the part that’s easy to skip and shouldn’t be: as shown so far, the proxy hides the key from the browser, but anyone who discovers the function URL can call it - and burn your API quota with your key. We’ve moved the secret, not secured the door. Lock the function to Entra ID. Create an app registration for the function API, expose a scope (the convention is access_as_user), and have the function validate incoming JWTs - signature, issuer, and audience, not just parsing the claims out. Parsing is reading the name tag; validating is checking the ID. On the SPFx side, this is pleasantly little work. Declare the permission in package-solution.json: "webApiPermissionRequests": [ { "resource": "my-function-api", "scope": "access_as_user" } ] And call the function with AadHttpClient - SPFx acquires and attaches the user’s token for you: const client = await this.context.aadHttpClientFactory .getClient("api://"); const response = await client.get( `https://my-function.azurewebsites.net/api/customers/${customerCode}/tasks`, AadHttpClient.configurations.v1 ); Now open the inspector again. There is a token in the request - but it’s the user’s own token: short-lived, scoped to your function only, and useless against the third-party API. That’s the whole difference between a secret and an identity. A stolen API key is everyone’s master key forever; a stolen user token is one person’s function access for an hour. Gotchas Watch what you log. Log method, path, and status - never headers. Otherwise the key you carefully kept out of the browser ends up in Application Insights, readable by everyone with portal access. You now front someone else’s quota. The vendor’s rate limits hit your key for all users combined. If the web part is chatty, add caching or throttling in the proxy before the vendor does it for you. CORS is on you. The browser is calling your function from a SharePoint origin - configure CORS on the Function App for your tenant’s SharePoint domain, not *. Consider narrowing the surface. A catch-all proxy forwards everything, including endpoints the web part never needed. If the API has destructive operations, whitelist the methods and paths you actually use. Wrapping Up A shared API key belongs on a server. Full stop. The moment it ships to a browser, every user has it, and you can’t take it back - most vendors will happily rotate the key for you, but you’ll be doing that dance on their schedule, not yours. The proxy costs about fifty lines: catch-all route, header hygiene, one server-side header swap, and Entra ID on the front door. That turns “every intranet user carries the master key” into “every request is a named user, calling a locked endpoint, seeing exactly what the web part shows them”. 🔒 --- ### Projektstyring med Microsoft platformen Forfatter: Dan Toft Dato: 2026-07-20 URL: https://pinksky.dk/blog/2026-07-20-projektstyring-med-microsoftplatformen/ Hvad er projektstyring? Projektstyring er en vigtig og svær disciplin, som alt for tit bliver forsømt eller overset - for hvad er et projekt egentlig? Hos Pinksky møder vi ofte virksomheder, der ikke ser sig selv som projektorganisationer, men når vi dykker ned i deres processer, står det soleklart, at de også er projektdrevne - de har bare ikke et fælles sprog, og de har ikke et fælles værktøj, der kan understøtte deres projektstyring. Det farligste for sådanne organisationer er, at de ikke har et fælles sprog og en fælles måde at arbejde på, men i stedet arbejder i siloer, enten på afdelingsniveau eller på individniveau - fyldt med “skygge-it” og uden en fælles forståelse af, hvad et projekt er, og hvordan man arbejder med projekter. Det betyder ikke kun, at der ikke findes et fælles overblik over, hvad der reelt foregår i organisationen. Ingen ved reelt, hvor data opbevares, og hvordan man finder det - og det gør det samtidig svært at sikre datakvaliteten. Det gør det også i praksis tæt på umuligt at overtage et projekt, hvis den person, der har arbejdet med det, pludselig ikke er tilgængelig. Man kan ikke finde de relevante dokumenter, og der er reelt ikke nogen, der har dataansvaret. Hvordan ved I, at der arbejdes ud fra de seneste krav, at data er let tilgængeligt for de rigtige personer, og at der er en fælles forståelse af, hvad projektet går ud på, og hvad det skal levere? Men måske værst af alt for IT: Hvordan ved vi, at data er vores og ikke bliver opbevaret i et tredjepartssystem, som vi ikke har kontrol over, og som vi ikke kan garantere sikkerheden på? Projektstyring med Microsoft platformen Med Microsoft platformen og de licenser, I sikkert allerede har, har I adgang til en lang række “byggeklodser”, som kan hjælpe jer med at få styr på jeres projekter og sikre, at I har et fælles sprog og en fælles måde at arbejde på - og vigtigst af alt, at I har et fælles, sikkert sted at opbevare jeres data, og at I kan sikre datakvaliteten. Hos Pinksky har vi mange års erfaring med at implementere Microsoft platformen på en måde, der understøtter forskellige typer af organisationer. Vi har set mange forskellige måder at arbejde med projekter på - og mange forskellige måder at implementere Microsoft platformen på - så vi ved, hvad der virker, og hvad der ikke virker. Og vi ved, hvordan man kan kombinere de forskellige værktøjer i Microsoft platformen, så de understøtter jeres projektstyring på en måde, der giver mening for jer. Det kan være, I er en salgsdrevet organisation, der starter i Dynamics 365 Sales, som er et fantastisk værktøj til at styre jeres salg. Men det er måske ikke det rigtige sted at lade projekterne leve, hvis de er dokumenttunge, kræver, at I samarbejder på tværs af afdelinger, og kræver, at I har et fælles sted at opbevare jeres data og sikre datakvaliteten. Her kunne SharePoint være det oplagte valg. Måske har I behov for opgavestyring sammen med jeres projekter, så ville det være oplagt at tilknytte Microsoft Planner til projektet. Måske har I behov for let kommunikation med jeres kunder i løbet af projektet, så er Microsoft Teams et oplagt valg, da det samler jeres kommunikation, gør det let at dele dokumenter via SharePoint og sikrer, at alle har adgang til de seneste versioner. Eller hvad med et flot dashboard, der giver jer et overblik over jeres projekter, og hvordan de skrider frem - og som hurtigt viser jer, hvor I skal sætte ind for at sikre, at jeres projekter bliver leveret til tiden og inden for budgettet? Her kunne Power BI være det oplagte valg. Kort fortalt Der er mange muligheder med Microsoft platformen, og vi kan hjælpe jer med at finde den rette kombination af værktøjer, der passer til jeres organisation, og sikre, at I får styr på jeres projekter og datakvaliteten. Læs mere om, hvordan vi kan hjælpe jer med projektstyring med Microsoft platformen her. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### Provision Forward, Never Backward: Checkpointing Durable Functions Forfatter: Jeppe Spanggaard Dato: 2026-07-16 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-07-durable-functions-provisioning-checkpoints/ I built a provisioning engine that creates a SharePoint team site for every new customer. Create the site, activate features, apply a template, seed a folder structure, add groups, register the site in an inventory list. Roughly ten steps, several minutes end to end, talking to SharePoint and Microsoft Graph the whole way. The first time step seven failed, I got to watch my engine try to create a site that already existed. That run taught me the rule this post is about: in a long provisioning flow, you don’t roll back when something fails. You checkpoint, and you resume forward. If Durable Functions are new to you, start with my intro to what they are and what they can do - this post builds on it. Why Rollback Is the Wrong Instinct When provisioning fails at step seven, you have a half-built site. The textbook answer is a compensation saga: undo steps six through one in reverse order. Delete the folders, detach the template, deactivate the features, delete the site, then recreate everything from scratch on the next attempt. I started sketching that and stopped halfway through the list, because every line was either dangerous or absurd. Deleting a site collection to work around a failed navigation tweak is using a crane to hang a picture. And some steps don’t even have an undo - you can’t meaningfully “unapply” a provisioning template that merged fields into existing lists. Here’s the thing rollback ignores: the six completed steps aren’t damage. They’re progress. The site is fine; what’s missing is the steps that haven’t run yet. So the only recovery that makes sense is forward: figure out where the run stopped, and continue from there. That reframes the problem completely. I don’t need compensation logic. I need to know, reliably, which steps finished. Carry the Progress in the State Durable Functions makes this natural, because an orchestrator already passes state to each activity and gets state back. The trick is to make “what’s done” part of that state. My orchestration state looks something like this: public sealed record SiteSetupState( string CustomerId, string SiteUrl, string[] CompletedSteps ); And the orchestrator walks its steps like this: var state = context.GetInput() ?? await CreateSite(context, customerId); string[] pipeline = [ nameof(ActivateFeatures), nameof(ApplyTemplate), nameof(SeedFolders), nameof(AddMemberGroups), nameof(RegisterSite), ]; foreach (var step in pipeline) { if (state.CompletedSteps.Contains(step)) { logger.LogInformation("{Step} already completed, skipping.", step); continue; } await context.CallActivityAsync(step, state, retryOptions); state = state with { CompletedSteps = [.. state.CompletedSteps, step] }; context.SetCustomStatus(state); } What’s happening here? The pipeline is just an ordered list of activity names. Nothing clever, and that’s the point - the interesting machinery is around the calls, not in them. Before each step, the orchestrator checks whether this step already ran. On a fresh run the array is empty and nothing is skipped. On a resumed run, this check is what fast-forwards past the finished work. After each successful step, the state is copied with the step name appended. An immutable with copy, so nothing mutates in place, which keeps replays honest. SetCustomStatus publishes the updated state on the orchestration instance. This is the checkpoint. It costs one line. That last line is doing more work than it looks like. Custom status is readable from outside the orchestration - through the management API, without touching the orchestration history. So the same call gives you two things: anyone polling the instance sees live progress (“three of six steps done”), and if the run fails, the last published state is sitting right there, telling you exactly where it stopped. Resume Is Just Input Because the checkpoint is a plain serializable record, resuming a failed run doesn’t need any special framework support. You read the failed instance’s custom status, and you start a new orchestration with that state as input: var failed = await client.GetInstanceAsync(failedInstanceId, getInputsAndOutputs: true); var checkpoint = failed.ReadCustomStatusAs(); await client.ScheduleNewOrchestrationInstanceAsync( nameof(SiteSetupOrchestrator), checkpoint); The new run enters the same loop, finds five steps in CompletedSteps, skips them in about a millisecond, and picks up at step six. No site deletion, no re-creation, no duplicate template apply. The half-built site becomes a five-sixths-built site, then a finished one. I like how little there is to this. The “resume feature” is the skip-check in the loop plus the fact that the input type and the checkpoint type are the same type. That’s it. The Gap That Idempotency Covers One honest caveat. The checkpoint is written after the activity succeeds, so there’s a window: the activity finishes, the process dies before the checkpoint lands. On resume, that step’s name isn’t in CompletedSteps, and it runs again. You can’t close that window - it’s inherent to doing the work and recording the work as two operations. What you do instead is make every activity safe to run twice: check before create, treat “already exists” as success, write with upserts. That’s a full topic on its own, but the division of labor is worth stating plainly: the checkpoint decides how often steps rerun, idempotency decides whether reruns hurt. You need both. The checkpoint alone has a crash window; idempotency alone means re-executing ten minutes of finished work on every hiccup. And never checkpoint before the call to close the window from the other side - then a crashed step gets skipped on resume, which is far worse. A step that runs twice is a wasted minute; a step that runs zero times is a broken site that says “Completed”. Ask me how I know. Gotchas Use the replay-safe logger. Orchestrator code replays from history every time the instance wakes up. context.CreateReplaySafeLogger(...) keeps your logs from repeating every completed step on each replay. A regular ILogger in an orchestrator will gaslight you. No clocks, no GUIDs in the orchestrator. DateTime.UtcNow, Guid.NewGuid(), and Random produce different values on replay and corrupt the history. Anything nondeterministic belongs inside an activity, including generated names and timestamps you want in the state. Custom status has a size limit (16 KB of JSON). A record with a customer ID, a URL, and an array of step names fits hundreds of times over, but don’t stuff a whole template or file manifest in there. Checkpoint the position, not the payload. Step names are a contract. The moment CompletedSteps is persisted anywhere - a failed instance you might resume next week - renaming an activity breaks the match and the step silently reruns (fine, if idempotent) or the resume misbehaves (not fine). Rename with the same care you’d give a database column. Report progress somewhere humans look. Custom status is great for machines; my engine also writes the current step name to a status column in the site inventory list, inside a try/catch that logs and swallows. A cosmetic status write must never kill a provisioning run. Wrapping Up Long provisioning flows fail in the middle, so design for resuming instead of undoing: carry a list of completed steps in the orchestration state, publish it as custom status after every step, skip completed steps on rerun, and feed the saved state back in to resume. Rollback is for databases. Provisioning goes forward. --- ### Durable Functions: A Function That Sleeps for a Week Forfatter: Jeppe Spanggaard Dato: 2026-07-15 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-07-what-are-durable-functions/ I had to build a provisioning engine. Every time a new customer lands in the business system, it creates a SharePoint team site for them: create the site, activate features, apply a template, seed a folder structure, add member groups, register the site in an inventory list. Roughly ten steps, several minutes end to end, and every single step talks to an API that can throttle, hiccup, or time out. My first instinct was a plain Azure Function. One trigger, one method, ten steps in a row. That instinct survived about a day. A plain function has a timeout measured in minutes. It holds its state in local variables, so when the process restarts mid-run (deployments, scale-in, Azure having a Tuesday), everything it knew is gone. Step six fails and your options are “run all ten steps again” or “reconstruct what happened from logs like a crime scene”. And it’s not like the step-sequencing part was new to me. I’d already built ProvisioningPipeline, an open-source .NET library for composing provisioning workflows from middleware-style steps, with hooks and parallel execution. It solves the “run these steps in order, cleanly” problem well. What no in-process pipeline can solve is the other half: a pipeline in memory still dies with the process, and all its progress dies with it. What I actually needed was a function that can run for a long time, remember how far it got, and pick up where it left off. That’s not a pattern you bolt onto a normal function. It’s a different tool, and Azure ships it: Durable Functions. Three Functions, One Flow A durable workflow is made of three kinds of functions, and the separation is the whole idea. The client is a normal function with a normal trigger - HTTP, queue, timer, whatever starts your process. Its only durable job is to schedule an orchestration and get out of the way: [Function(nameof(StartProvisioning))] public async Task Run( [ServiceBusTrigger("provisioning")] ProvisioningMessage message, [DurableClient] DurableTaskClient client) { await client.ScheduleNewOrchestrationInstanceAsync( nameof(SiteSetupOrchestrator), message.ToSetupRequest()); } The orchestrator is the recipe. It calls the steps in order and holds the workflow logic, but does no real work itself: [Function(nameof(SiteSetupOrchestrator))] public async Task RunOrchestrator( [OrchestrationTrigger] TaskOrchestrationContext context) { var request = context.GetInput(); var site = await context.CallActivityAsync(nameof(CreateSite), request); await context.CallActivityAsync(nameof(ActivateFeatures), site); await context.CallActivityAsync(nameof(ApplyTemplate), site); await context.CallActivityAsync(nameof(SeedFolders), site); await context.CallActivityAsync(nameof(AddMemberGroups), site); await context.CallActivityAsync(nameof(RegisterSite), site); } The activities are where the actual SharePoint and Graph calls live. Each one is a small, self-contained function: CreateSite creates a site, ApplyTemplate applies a template, and so on. What’s happening here? The client schedules an orchestration instance and returns immediately. Nothing waits around; the run now has a life of its own, with an instance ID you can use to check on it later. The orchestrator reads like a script: six awaited steps, top to bottom. This is the part that would be a state machine diagram in Logic Apps or a pile of queues in a hand-rolled solution. Here it’s just code, with loops and try/catch available when you need them. Every CallActivityAsync is a checkpoint boundary. The runtime records that the step completed, and what it returned, before moving on. Activities take input, do I/O, return output. They don’t know they’re part of a workflow, which keeps them easy to write and easy to test. The “Durable” Part Is a History Table Here’s what separates this from a normal async method: the orchestrator’s progress doesn’t live in memory. Every scheduled activity and every result gets written to a history table in storage as an event. When the process dies mid-run - and over a multi-minute workflow across enough customers, it will - the runtime picks the orchestration up on another instance and replays it. The orchestrator code runs again from the top, but every activity that already completed doesn’t re-execute; its recorded result comes straight back from history. The replay fast-forwards through the finished steps in milliseconds and resumes real work at the first step without a recorded result. It’s a save file in a video game. The console losing power doesn’t send you back to level one; you reload and continue from the last save point. The orchestrator saves after every activity, and it never plays a level twice. That same mechanism is why an orchestration can wait for days without costing you anything. Awaiting a durable timer or an external event just means “write the wait to history and unload from memory”. No process is alive, no compute is billed, and when the timer fires next week, replay reconstructs the state and continues. A function that sleeps for a week, literally. What Else Is in the Box Sequential steps are the basics. A few more capabilities, through the provisioning lens: Per-step retries. Every activity call can carry a retry policy, and this is the built-in retry logic worth knowing about. Mine looks like this: private static readonly TaskOptions ActivityRetryOptions = new( new RetryPolicy( maxNumberOfAttempts: 10, firstRetryInterval: TimeSpan.FromSeconds(5), backoffCoefficient: 2.0, maxRetryInterval: TimeSpan.FromMinutes(5))); await context.CallActivityAsync(nameof(ApplyTemplate), site, ActivityRetryOptions); If ApplyTemplate throws because SharePoint returned a 429, the runtime waits 5 seconds and reruns the activity. Then 10, then 20, backing off exponentially up to 5 minutes, for up to 10 attempts. The orchestration just sits there durably waiting between attempts. For workflows built on APIs that throttle, this one feature pays for the whole ticket. Fan-out/fan-in. Steps that don’t depend on each other can run as parallel activities and be awaited together with Task.WhenAll. Seeding twelve folders one by one is a for-loop; seeding them as twelve parallel activities is the same loop without the awaits until the end. Durable timers. context.CreateTimer(...) gives you delays and polling loops that survive restarts. Waiting 30 seconds for SharePoint to finish creating a site is a timer, not a Task.Delay and a prayer. External events. An orchestration can pause until something outside signals it - WaitForExternalEvent is how you build “wait for an admin to approve, then continue”, even if the admin takes four days. Status from the outside. Every instance can be queried by ID: is it running, what step is it on, did it fail and why. My provisioning engine exposes this so the business system can show “site is being created” instead of a shrug. What It Doesn’t Do for You An honest intro should include the bill. Retries are per step, not per run. The retry policy reruns a failing activity. If an activity exhausts all ten attempts, the orchestration fails, and a failed run stays failed - nothing in the box restarts it from where it stopped. Resuming a dead run is a pattern you build yourself on top of these primitives. Retried activities rerun whole. When attempt two starts, it starts from the activity’s first line, not from where attempt one died. An activity that created a site and then failed will try to create the site again. Every activity has to be written to survive running twice, and that’s on you. Orchestrator code lives under house rules. Because the orchestrator replays, it must be deterministic: same history in, same decisions out. No DateTime.UtcNow, no Guid.NewGuid(), no HTTP calls, no reading config that might change between replays. Anything nondeterministic belongs in an activity. The rules are simple but unforgiving, and the failure mode is a corrupted run, not a compile error. None of these are dealbreakers. They’re the shape of the tool: the runtime guarantees your progress is never lost, and in exchange you write steps that tolerate being repeated. Gotchas Use the replay-safe logger in orchestrators. context.CreateReplaySafeLogger(...) suppresses log lines during replay. A regular ILogger logs every completed step again on every replay, and the duplicated log stream will have you debugging problems you don’t have. Keep activities small and single-purpose. One activity per step means one checkpoint per step. A mega-activity that does five things gives you one checkpoint for five steps, and a failure at thing four reruns things one through three. The retry policy can’t tell a 429 from a typo. It retries every exception the same way, including the ones that will never succeed, like a broken config value. Distinguishing “try again” from “stop, a human needs to fix this” takes extra work. Don’t sneak I/O into the orchestrator. It compiles, it works on the happy path, and it breaks determinism the first time a replay gets a different answer. If it touches the network or the clock, it’s an activity. Wrapping Up A Durable Function is a workflow that remembers where it was: an orchestrator sequences the steps, activities do the work, and the runtime checkpoints every step so crashes, restarts, and week-long waits don’t lose progress. If your process has numbered steps, talks to flaky APIs, and takes longer than a request, it’s a fit - provisioning was mine. --- ### Hvorfor er kontraktstyring vigtigt? Forfatter: Dan Toft Dato: 2026-07-13 URL: https://pinksky.dk/blog/2026-07-13-hvorfor-er-kontraktstyring-vigtigt/ I langt de fleste virksomheder er kontrakter spredt i indbakker, mapper, måske endda i arkivskabe. Det er sjældent et spørgsmål om sjusk. Det er et spørgsmål om, at kontrakter lever længere end de processer, der skabte dem. Hvad koster manglende kontraktstyring? Konsekvensen af spredte kontrakter er ikke synlig med det samme, men den kommer snigende med tiden, uanset om det er, når en medarbejder stopper, når kaffemaskinen pludselig ikke længere bliver fyldt, eller i værste fald, når en leverandør stiller krav, I ikke vidste, I havde aftalt. De mest typiske konsekvenser af manglende kontraktstyring er: Glemte frister. Aftaler med automatisk fornyelse bliver forlænget, fordi opsigelsesvinduet blev overset. Spildt tid. Når en kontrakt skal findes frem til en forhandling, en revision eller en tvist, går der timer med at lede i mapper, indbakker og arkivskabe, og måske kan den slet ikke findes, fordi den er blevet gemt på en nu tidligere medarbejders private drev. Uvished. Uden et klart overblik over kontrakterne er det nærmest umuligt at vide, om man har de rigtige aftaler på plads, eller hvor ansvaret ligger. Hvad god kontraktstyring giver jer Struktureret kontraktstyring handler ikke om flere regler - det handler om at samle kontrakterne i ét enkelt overblik og få struktur på de processer, der sikrer, at de bliver vedligeholdt og ajourført. Ét samlet overblik over alle aftaler, deres status, udløb og hvem der er ansvarlig. Automatiske påmindelser i god tid før opsigelses- og fornyelsesfrister. Styr på versioner og adgang: Slut med tvivlen om, hvorvidt det er kontrakt_2026_v1_draft.docx eller kontrakt_2026_v1.2_draft_final.docx, der er den gældende. Og naturligvis styr på adgangen, så kun de medarbejdere, der har behov for at tilgå kontrakten, kan se den. Gevinsten er til at måle: færre uønskede forstyrrelser, hurtigere svar og et langt bedre udgangspunkt, når aftaler skal genforhandles. Og vigtigst af alt mindre tidsspild! I har sandsynligvis allerede platformen Der findes masser af moderne kontraktstyringssystemer på markedet, men de kræver ofte nye licenser og integrationer. Men hos Pinksky har vi specialiseret os i at få mere ud af de platforme, I allerede benytter, uden meromkostninger. Vi har rig erfaring i at bygge kontraktstyringsløsninger på Microsoft platformen. Med standard SharePoint Online og Power Automate, som I sikkert allerede har licenser til, kan vi bygge en løsning, der er tilpasset netop jeres behov, uden at I behøver investere i nyt software og dyre licenser, huske flere logins eller flytte data ud af huset. Og vigtigst af alt er løsningen forberedt til Microsoft 365 Copilot, så I kan få AI til at hjælpe med at finde kontrakter, opsummere indhold og generere udkast til nye aftaler. Hos Pinksky har vi bygget kontraktstyring på Microsoft 365 for en række danske virksomheder - uden nye licenser og uden at flytte data ud af huset. Lyder det interessant? Lad os tage en uforpligtende snak om, hvordan vi kan hjælpe jer - og hvordan I får mest muligt ud af jeres Microsoft-platform. Fordi hos Pinksky er vi in IT together Kontakt os --- ### Stop Re-Downloading Unchanged Blobs: Let Azure Answer 304 for You Forfatter: Jeppe Spanggaard Dato: 2026-07-05 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-07-azure-blob-storage-etag-304/ I have an Azure Functions backend whose job, among other things, is serving a web app’s static files out of Blob Storage: JS bundles, fonts, icons, an index.html. Same files, same users, many times a day. And for a while, every single request did the same dumb thing: download the blob from storage, push the bytes to the browser. The file hadn’t changed since five seconds ago. The user’s browser literally had an identical copy already. Didn’t matter - full download from storage, full response to the client, every time. I was paying latency and moving bytes just to deliver files nobody actually needed re-delivered. The fix was already sitting in every storage response I’d been ignoring: the ETag. Thirty Seconds on ETags Every blob response (and every properties call) includes an ETag header, something like "0x8DC5F3A2B1E4D70". Storage changes it whenever the blob’s content or metadata changes. It costs nothing, it’s always there. On the HTTP side, the handshake is old and boring and great: the server sends ETag with a response; the browser saves it; next time the browser asks for the same URL it includes If-None-Match: ; if the server still has the same version, it answers 304 Not Modified with no body, and the browser uses its cached copy. Boring. Reliable. Built into every browser since forever. The Naive Version My first pass was the obvious one: var response = await blob.DownloadStreamingAsync(cancellationToken: ct); return new FileStreamResult(response.Value.Content, contentType); Works fine. But look at what the function actually is in this setup: a photocopier standing between storage and the browser, dutifully copying files that both sides already agree on. The browser has the file. Storage knows the file hasn’t changed. And my function in the middle is the only one who never asked. The Trick: Be a Pipe, Not a Cache The Azure SDK supports conditional requests natively. So instead of checking anything myself, I just hand the browser’s ETag straight to storage: public async Task GetAsync(string path, string? ifNoneMatch, CancellationToken ct) { var blob = _container.GetBlobClient(path); var options = new BlobDownloadOptions(); // Pass the client's ETag to the storage service as a conditional request. // Azure Blob Storage will short-circuit at the network level and return a 304, // meaning we never transfer the file body when the client is up to date. if (!string.IsNullOrEmpty(ifNoneMatch)) options.Conditions = new BlobRequestConditions { IfNoneMatch = new ETag(ifNoneMatch) }; try { var response = await blob.DownloadStreamingAsync(options, ct); if (response.GetRawResponse().Status == 304) { // Client's copy is current - signal it without a body. return new AssetResponse { ETag = ifNoneMatch!, IsNotModified = true }; } var details = response.Value.Details; return new AssetResponse { ETag = details.ETag.ToString(), Content = response.Value.Content, ContentType = details.ContentType, }; } catch (RequestFailedException ex) when (ex.Status == 404) { return null; } } What’s happening here? The browser’s If-None-Match value goes into BlobRequestConditions.IfNoneMatch. That turns the download into a conditional request, the same ETag handshake, just one hop deeper. The comparison happens inside Azure Storage, not in my code. On a match, storage answers 304 and the response has no body. The file bytes never even reach my function. On a miss (file changed, or first visit), it’s a normal download, and DownloadStreamingAsync gives me a live stream I pass straight through without buffering the whole file in memory. Missing blob → the RequestFailedException filter turns a 404 into a null, which the endpoint maps to a proper NotFound. There is no dictionary of ETags, no memory cache, no invalidation logic. I didn’t build a cache, I connected two caches that already existed: the browser’s and storage’s own knowledge of its blobs. Finishing the Loop Toward the Browser The function endpoint relays the result: req.HttpContext.Response.Headers[HeaderNames.ETag] = asset.ETag; if (asset.IsNotModified) return new StatusCodeResult(StatusCodes.Status304NotModified); req.HttpContext.Response.Headers[HeaderNames.CacheControl] = asset.IsImmutable ? "public, max-age=31536000, immutable" : "no-cache"; return new FileStreamResult(asset.Content!, asset.ContentType!); Two details worth pausing on: no-cache doesn’t mean “don’t cache”. It means “cache it, but revalidate before using it”. That revalidation is exactly the If-None-Match round trip, which the pass-through just made nearly free - a header-only 304 instead of a file download. This is what index.html gets. Content-hashed files skip the conversation entirely. My build tool outputs filenames like app.ByJ3R0Az.js - the hash is the version. Those get max-age=31536000, immutable, so the browser never revalidates them at all. A new deploy produces a new filename, which is simply a different URL. The ETag dance is only for files whose names stay stable while their content changes. Why I Like This Better Than a Memory Cache My first instinct was an IMemoryCache of blob contents in the function. I’m glad I resisted: Nothing to size. No “how many MB of blobs do I keep in memory” question. Nothing to invalidate. The ETag comparison is against live storage, so a deploy is visible on the very next request. Stale-cache bugs can’t exist because there’s no cache to go stale. Scale-out safe. Ten function instances behave identically because none of them hold state. A per-instance memory cache would give ten different answers for 60 seconds after every deploy. The thing that owns the data does the validation. Everyone else just forwards headers. 📮 Gotchas Round-trip the ETag string untouched. The quotes are part of the value. Trim them, “clean them up”, or re-wrap them and the comparison silently never matches again - everything still works, you just download every file every time and never notice. No ETag out, no If-None-Match back. Browsers only revalidate if your response included the ETag header in the first place. Forget it on one branch (error paths are a classic) and that file is a full download forever. no-cache still costs one round trip per file per load. That’s the deal: a header-only 304 instead of a body. For files that never change, don’t negotiate - content-hash the filename and go immutable. Wrapping Up The cheapest download is the one that never happens. Blob Storage already fingerprints every blob and already speaks conditional requests - most backends just never pass the browser’s question along. Forward If-None-Match, relay the 304, and let the two parties who actually know the answer talk to each other. --- ### Serve a Static Site From Blob Storage With an Azure Function Forfatter: Jeppe Spanggaard Dato: 2026-06-25 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-06-azure-function-blob-storage-static-site-proxy/ I needed to host an internal handbook. Nothing fancy: a static site full of onboarding guides and how-tos, built with a static site generator into a folder of HTML, CSS, JS, and images. The one hard requirement: only signed-in employees get to see it. And that requirement quietly kills all the easy hosting options. Blob Storage’s static website feature? Public. A plain CDN? Public. The moment “who is asking” matters for every single file - not just the pages, the images and search index too - the files have to live somewhere private, and something with auth has to sit in front and hand them out. My something is one Azure Function. One endpoint. It serves the entire site. The Blob Container Is the Filesystem There’s no clever mapping layer. I upload the build output into the container exactly as the generator produced it: index.html guides/onboarding/index.html guides/expenses/index.html assets/main.css assets/search.js images/office-map.png The container layout is the URL space. Deploying a new version of the site is one CLI command: az storage blob upload-batch --source ./public --destination site-content --overwrite The Catch-All Function Here’s the whole thing, trimmed to its skeleton: [Function("Site")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "{*path}")] HttpRequest req, string? path, CancellationToken ct) { // Every file goes through this gate - pages, scripts, images, all of it. if (!await _auth.IsSignedInAsync(req)) return new UnauthorizedResult(); var blobPath = MapToBlobPath(path); var blob = _container.GetBlobClient(blobPath); try { var response = await blob.DownloadStreamingAsync(cancellationToken: ct); var contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath); return new FileStreamResult(response.Value.Content, contentType); } catch (RequestFailedException ex) when (ex.Status == 404) { return new NotFoundResult(); } } private static string MapToBlobPath(string? path) { if (string.IsNullOrEmpty(path)) return "index.html"; // "/" → the front page if (path.EndsWith('/') || !Path.HasExtension(path)) return $"{path.TrimEnd('/')}/index.html"; // "/guides/onboarding/" → its index return path; // "/assets/main.css" → as-is } What’s happening here? Route = "{*path}" is the whole trick. The * makes it a catch-all: one route binding matches /, /guides/onboarding/, /assets/main.css, anything. One function, every file on the site. The auth guard runs before anything touches storage. That’s the entire reason this setup exists - a static host can protect a site, this protects every byte. (How IsSignedInAsync works - cookies, Entra ID, whatever fits - is its own topic; the point is it’s one if at the top.) MapToBlobPath does the job a web server normally does silently: default documents. / becomes index.html, extension-less routes like /guides/onboarding become guides/onboarding/index.html. Forget this and your front page is a 404. DownloadStreamingAsync returns a live stream, and FileStreamResult pipes it straight to the response. The function never buffers a whole file in memory - a 4 MB image flows through, it doesn’t land here. A missing blob throws RequestFailedException with status 404; the exception filter turns that into a clean NotFoundResult instead of a pre-flight existence check (which would just be a second storage call). Content Types: The Unglamorous Part That Breaks Everything If you serve HTML with the wrong Content-Type, the browser doesn’t render your page, it downloads it. Ask me how I know. Best option: set correct content types on the blobs at upload time (upload-batch infers most of them). But blobs uploaded by hand or by older scripts often end up as application/octet-stream, so I keep a fallback map: private static string ContentTypeFor(string path) => Path.GetExtension(path).ToLowerInvariant() switch { ".html" => "text/html", ".css" => "text/css", ".js" => "text/javascript", ".json" => "application/json", ".svg" => "image/svg+xml", ".png" => "image/png", ".woff2" => "font/woff2", _ => "application/octet-stream", }; What You Get for Free Auth on every file. Not just page-level protection - the org chart PNG and the search index JSON are exactly as protected as the pages. One deploy target. Build the site, upload-batch the folder, done. No web server to configure, nothing to restart. Generator-agnostic. The function doesn’t know or care if the folder came from Hugo, Astro, Docusaurus, or hand-written HTML. Cheap environments. Staging is just a second container and one config value. Rollback is re-uploading yesterday’s build folder. One More Trick: Stop Re-Downloading Unchanged Files There’s an elephant in the version above: every request downloads the full file from storage and pushes it to the browser - even when the file hasn’t changed in weeks and the browser has a perfect copy from five minutes ago. The fix is already sitting in Blob Storage: every blob has an ETag, a version fingerprint that changes on every write. Browsers already know the game - once they’ve seen an ETag header, they send it back as If-None-Match on the next request. All the function has to do is forward that header into the SDK call: var options = new BlobDownloadOptions(); var ifNoneMatch = req.Headers.IfNoneMatch.ToString(); if (!string.IsNullOrEmpty(ifNoneMatch)) options.Conditions = new BlobRequestConditions { IfNoneMatch = new ETag(ifNoneMatch) }; var response = await blob.DownloadStreamingAsync(options, ct); if (response.GetRawResponse().Status == 304) { // Browser's copy is current - Azure never sent us the body at all. req.HttpContext.Response.Headers[HeaderNames.ETag] = ifNoneMatch; return new StatusCodeResult(StatusCodes.Status304NotModified); } req.HttpContext.Response.Headers[HeaderNames.ETag] = response.Value.Details.ETag.ToString(); var contentType = response.Value.Details.ContentType ?? ContentTypeFor(blobPath); return new FileStreamResult(response.Value.Content, contentType); The beautiful part: the ETag comparison happens inside Azure Storage, not in your code. On a match, storage answers 304 with no body - the file bytes never reach the function, and the function relays a bare 304 to the browser. No server-side cache, nothing to invalidate, and it works identically across scaled-out instances because nobody holds any state. The browser is the cache, Azure is the validator, and the function stays what it was: a pipe. Two rules to make it stick: always set the ETag response header (no ETag out means the browser never asks conditionally again), and pass the value through untouched - the quotes are part of it, and “cleaning them up” silently breaks the match forever. Gotchas You are the web server now. Default documents, trailing slashes, 404 pages - all the invisible things a real web server does are your job. MapToBlobPath above is the minimum, not the maximum. SPA? Then unknown routes need a fallback. For a client-side-routed app, a route with no matching blob should serve index.html (200, not 404) and let the router sort it out. For a docs site like mine, a real 404 is correct. Cold starts sit in front of your CSS. The function is in the path of every byte, so a consumption-plan cold start delays the whole page, not just an API call. For an internal tool I can live with it; know your tolerance. Wrapping Up One catch-all route, a private container, and a twenty-line function: a whole authenticated website with no web server to run. Add the If-None-Match pass-through and unchanged files stop moving entirely. The container is the filesystem, the function is the doorman. 🚪 --- ### Your Add-in Gets a Free Folder in Everyone's OneDrive (Use It) Forfatter: Jeppe Spanggaard Dato: 2026-06-15 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-06-outlook-addin-onedrive-approot-preferences/ My Outlook add-in has a handful of user preferences: which tab to open by default, language, a “don’t show this tip again” flag. Small stuff. A single JSON blob. So where do you put it? localStorage is per-device, per-browser, and mobile WebViews evict it whenever they feel like it. A backend with a database is a lot of infrastructure for one JSON file per user. And then it hit me: every single one of my users already has cloud storage my add-in can reach. Their OneDrive. What the App Folder Actually Is First time your app touches approot, OneDrive creates a folder under Apps/ (the name comes from your Entra app registration). It’s the user’s storage, visible to them in OneDrive, but sandboxed for you: request Files.ReadWrite.AppFolder and that folder is all your app can see. No scary “this app can read all your files” consent screen. One endpoint to remember: /me/drive/special/approot Saving Settings I use PnPjs (@pnp/graph), so a save is three lines: import { SpecialFolder } from "@pnp/graph/files"; async function saveUserSettings(settings: UserDefinedSettings): Promise { const appRoot = graphFI.me.drive.special(SpecialFolder.AppRoot); await appRoot.upload({ content: JSON.stringify(settings), filePathName: "user-settings.json", contentType: "application/json", }); } No PnPjs? The raw Graph equivalent is a single PUT: PUT /me/drive/special/approot:/user-settings.json:/content. Reading Settings (and Surviving the First Run) Reading has one twist: the very first time a user opens your add-in, the file doesn’t exist yet. That’s not an error, that’s a new user. Plan for it: const defaultSettings: UserDefinedSettings = { openPreviewInNewTab: false, returnToHomeAfterArchive: false, hasSeenPinTip: false, }; async function getUserSettings(): Promise { try { const appRoot = graphFI.me.drive.special(SpecialFolder.AppRoot); const children = await appRoot.children(); const fileItem = children.find((item) => item.name === "user-settings.json"); if (!fileItem) { return defaultSettings; // first run - no file yet } const blob = await graphFI.me.drive.getItemById(fileItem.id!).getContent(); const json = JSON.parse(await blob.text()); return { openPreviewInNewTab: json.openPreviewInNewTab ?? defaultSettings.openPreviewInNewTab, returnToHomeAfterArchive: json.returnToHomeAfterArchive ?? defaultSettings.returnToHomeAfterArchive, hasSeenPinTip: json.hasSeenPinTip ?? defaultSettings.hasSeenPinTip, }; } catch (error) { console.error("Error getting user settings:", error); return defaultSettings; // any failure - the add-in still works } } What’s happening here? Missing file → defaults, not an exception. New users get a working add-in, not an error toast. Every field is parsed with a ?? fallback. When I ship a new setting next month, users with an old user-settings.json don’t break, the new field just gets its default. The whole thing is wrapped in try/catch that returns defaults. Settings are a nice-to-have; they should never take the add-in down with them. Two practical habits: load the settings once at boot and cache them (don’t re-read OneDrive on every render), and save optimistically - update your local state immediately, fire the upload without awaiting it. And the best part comes for free: change a setting on desktop, open the phone, it’s there. 🎁 Not Just Outlook Add-ins: SPFx Too This pattern isn’t tied to Outlook at all, it just needs a user context and a Graph token. That makes it a great fit for SPFx web parts as well. Web part properties in SPFx are per-instance and per-page, and usually something an editor configures, not the end user. If you want user-level preferences - a collapsed/expanded state, a preferred view, a dismissed banner - that follow the user across every page and site where your web part lives, the App Folder solves it with zero extra infrastructure. Grab MSGraphClientV3 from the SPFx context and hit the same /me/drive/special/approot:/user-settings.json:/content endpoint. Same folder, same JSON file, same permission model. Gotchas Ask for Files.ReadWrite.AppFolder, not Files.ReadWrite. If the app folder is all you need, the scoped permission gets you a much friendlier consent prompt and a much smaller blast radius. The folder is named after your app registration’s display name. Rename the registration and users get a new empty folder, your settings file stays behind in the old one. Users can see (and delete) the folder. It’s their OneDrive. Treat a missing file as a normal state, always, not just on first run. Coming from PnPjs v2, .get() is not a function. The imports above are v3 selective imports: @pnp/graph/files brings the drive methods onto the fluent chain, and the v2 .get() terminator is gone in favour of getContent(), getItemById() and friends. If a v2-era snippet dies on .get is not a function, you’re missing the selective import, not the package. Wrapping Up User preferences don’t need a database, and they deserve better than localStorage. The OneDrive App Folder is the middle ground that’s easy to miss: zero infrastructure on your side, real cloud persistence on theirs, and a permission model that only exposes what your app actually needs. Outlook add-in, SPFx web part, anything with a Graph token - same trick everywhere. --- ### Outlook Mobile Has No getAsFileAsync: Fall Back to Graph Forfatter: Jeppe Spanggaard Dato: 2026-06-05 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-06-outlook-addin-graph-fallback-mobile/ In my last post I showed how declaring a low Mailbox requirement set in the manifest got my Outlook add-in to show up on mobile. Great. Button’s there, task pane opens, everything looks alive. Then the user taps “Archive to SharePoint” and the whole feature stands on one API: getAsFileAsync(), which hands you the entire email as a file. That API lives in Mailbox 1.14. Outlook mobile doesn’t have it. So now I had the opposite problem from last time: instead of an invisible add-in, I had a visible add-in with a dead button. Honestly, that’s worse. Visible ≠ functional. This post is about the second half of the mobile story: getting the same email bytes through a different door. The Desktop Path: Just Ask Outlook On desktop and web, Office.js does all the work. You ask the host for the current message as a file, and it hands you the EML as base64. No network call, no token, no permissions dance, Outlook already has the email. function getMessageAsBlob(): Promise<{ bytes: Uint8Array; blob: Blob }> { return new Promise((resolve, reject) => { if (!Office.context.requirements.isSetSupported("Mailbox", "1.14")) { return reject(new Error("This client does not support Mailbox 1.14 (getAsFileAsync).")); } const item = Office.context.mailbox.item as Office.MessageRead; item.getAsFileAsync((asyncResult) => { if (asyncResult.status === Office.AsyncResultStatus.Failed) { return reject(asyncResult.error); } // getAsFileAsync returns the EML as base64 const bytes = Uint8Array.from(atob(asyncResult.value), (c) => c.charCodeAt(0)); const blob = new Blob([bytes], { type: "message/rfc822" }); resolve({ bytes, blob }); }); }); } Notice the first thing this function does: check isSetSupported("Mailbox", "1.14") and reject if it’s not there. That reject is not an error case, it’s a signal. Remember it, it becomes important in a minute. The Graph Path: Ask Exchange Instead Here’s the realization that saved the mobile experience: Outlook doesn’t own your email, Exchange does. The host app is just one way to get at it. Microsoft Graph is another, and Graph has an endpoint that returns the raw MIME content of any message: async function getMessageAsBlobViaGraph(): Promise<{ bytes: Uint8Array; blob: Blob }> { const item = Office.context.mailbox.item as Office.MessageRead; if (!item?.itemId) { throw new Error("No item ID available for Graph-based archive."); } const token = await getToken(["Mail.ReadWrite.Shared"]); const mimeUrl = `https://graph.microsoft.com/v1.0/me/messages/${encodeURIComponent(item.itemId)}/$value`; const resp = await fetch(mimeUrl, { method: "GET", headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) { throw new Error(`Error ${resp.status}: ${resp.statusText}`); } const buffer = await resp.arrayBuffer(); const bytes = new Uint8Array(buffer); const blob = new Blob([buffer], { type: "message/rfc822" }); return { bytes, blob }; } What’s happening here? item.itemId is all we need from Office.js, and reading it only requires Mailbox 1.1. That’s the beautiful part: the manifest floor from the last post promised almost nothing, and this fallback only needs almost nothing. The $value segment on /me/messages/{id} tells Graph to skip the JSON representation and return the raw RFC 822 message, the same EML you’d get from getAsFileAsync. We wrap it in a Blob with message/rfc822, the exact same shape the desktop path produces. Same email, different source. The token comes from MSAL with the Mail.ReadWrite.Shared scope. And yes, getting a token inside Outlook mobile is its own adventure, the magic words are Nested App Auth (NAA), where sign-in goes through the Microsoft Authenticator app instead of a browser popup. That one deserves its own post someday. One honest caveat: I pass item.itemId straight to Graph without converting it. That works because modern hosts (including mobile) hand out REST-format IDs. If your add-in also runs in older Outlook clients that still produce EWS-format IDs, run the ID through Office.context.mailbox.convertToRestId() first, or Graph will give you a very confusing 404. Don’t Branch, Fall Back So we have two functions. The tempting way to pick between them is if (isMobile) { ... } else { ... }. I did it differently, and I’m glad I did: let messageAsBlob: { bytes: Uint8Array; blob: Blob }; try { messageAsBlob = await getMessageAsBlob(); } catch { // Office.js getAsFileAsync unavailable (e.g. mobile) - fall back to Graph if (getMessageAsBlobViaGraph) { messageAsBlob = await getMessageAsBlobViaGraph(); } else { throw new Error("Email export is not available on this platform."); } } Always try Office.js first, and let the failure route you to Graph. Remember that reject on the 1.14 check? On mobile it fires every time, and the catch block quietly takes the Graph road instead. Why is this better than platform-branching? Because it’s capability-based, not platform-based. If some desktop client out there is running an older Outlook without 1.14, it gets the fallback for free. I never had to predict which platforms are broken, the code just asks “did the good path work?” and moves on. I do add one belt-and-suspenders detail: the Graph function is only injected into this flow when platform detection says we’re on mobile. On desktop it’s undefined, so a genuinely broken desktop fails loudly with a clear message instead of silently making Graph calls I didn’t expect. Same Blob, Same Pipeline This is the part I want you to steal. Both functions return the same thing: { bytes: Uint8Array; blob: Blob } // type: "message/rfc822" Everything after this point - naming the file, uploading it to SharePoint, progress reporting, conflict handling - is one code path. The upload logic has no idea whether the bytes came from Office.js or from a Graph call. There’s no if (isMobile) sprinkled through the upload code, no duplicated pipeline, nothing. That’s the whole trick, really. A good fallback isn’t a second feature, it’s a second source feeding the same feature. The moment your fallback needs its own downstream handling, you’ve built two features and doubled your bugs. Gotchas I Hit Along the Way ID formats will bite you. REST IDs and EWS IDs look similar enough (long base64-ish strings) that you won’t spot the difference by eye. If Graph returns 404 for a message you’re literally looking at, check the ID format before questioning your sanity. convertToRestId() is the fix. Graph only knows what the server knows. getAsFileAsync reads from the host, Graph reads from Exchange. For a message that just arrived, the server side can lag a beat behind what Outlook is already showing you. Rare, but real. No retry for free. Office.js calls fail locally and instantly. The Graph call is a network request that can hit throttling or transient errors, and a bare fetch won’t retry anything. Mine throws on non-OK responses and lets the surrounding archive flow surface the error; depending on your feature, a retry with backoff might be worth it. Wrapping Up The manifest post got the add-in through the door on mobile. This post made it actually earn its place there: use Office.js when the host can deliver, fall back to Graph when it can’t, and make both paths hand over identical bytes so the rest of your app never has to care. Outlook is just one door to the mailbox. When it’s locked, Graph is around the back. 🚪 --- ### There's No Popup on a Phone: Nested App Auth for Outlook Add-ins Forfatter: Jeppe Spanggaard Dato: 2026-05-28 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-05-outlook-addin-nested-app-auth-mobile/ This is the third post in what accidentally became a trilogy about getting my Outlook add-in to work on mobile. Post one made the add-in visible on the phone. Post two made it functional by falling back to a Microsoft Graph call when getAsFileAsync wasn’t there. And in that second post I wrote one very casual line: “The token comes from MSAL.” Yeah. About that. On desktop, MSAL gets a token by opening a browser popup where the user signs in. On mobile there is no browser. Your add-in runs in a WebView inside the Outlook app. There’s no window to pop, no tab to redirect, nowhere for the classic OAuth dance to happen. My first attempt at calling Graph from the phone died right there, not on the API call, on the sign-in before it. The fix has a name: Nested App Auth (NAA). Why Normal MSAL Dies on Mobile The standard MSAL flows (loginPopup, loginRedirect) both assume your code lives in a browser. A popup needs window.open. A redirect needs the page to navigate away to login.microsoftonline.com and come back with your app still knowing what it was doing. Inside Outlook on iOS or Android, neither is true. You’re in a WKWebView (or the Android equivalent) that Outlook controls. window.open goes nowhere useful, and a redirect would navigate the task pane itself into oblivion. But think about what’s around your WebView: the Outlook app. A fully signed-in, Microsoft-identity-aware native app that already knows exactly who the user is. NAA is the mechanism that lets your nested add-in say “hey host, you handle the identity stuff” and get tokens brokered through Outlook itself. When silent brokering isn’t enough, the host hands off to Microsoft Authenticator for the interactive part, a real native auth experience instead of a popup that can’t exist. The Azure Part: One Weird Redirect URI Before any code, your Entra app registration needs to opt in to being brokered. That’s done with a redirect URI of type Single-page application in this format: brk-multihub://your-add-in-domain.com Origin only, no subpaths: ✅ brk-multihub://contoso.com ✅ brk-multihub://localhost:3000 (for dev) ❌ brk-multihub://contoso.com/taskpane This URI is you telling the Microsoft identity platform: “I consent to having my auth brokered by trusted Microsoft 365 hosts.” The multihub part means it’s not just Outlook, the same registration works when your add-in runs brokered inside Word, Excel, PowerPoint, or Teams. One extra note if your add-in also runs in Word/Excel/PowerPoint on the web: those need an additional plain SPA redirect URI pointing at the actual page that requests tokens (your taskpane HTML), because the browser there still uses a standard flow. The MSAL Part: One Config, Two Worlds Here’s the neat thing about how NAA lands in code: you don’t write a mobile version and a desktop version. You write one init function and let it fall back. import { createNestablePublicClientApplication, PublicClientApplication, type IPublicClientApplication, type Configuration, } from "@azure/msal-browser"; let isNAAMode = false; async function initMsal(): Promise { const msalConfig: Configuration = { auth: { authority: "https://login.microsoftonline.com/common", clientId: clientId, supportsNestedAppAuth: true, }, }; try { const app = await createNestablePublicClientApplication(msalConfig); isNAAMode = true; return app; } catch { // NAA unavailable - fall back to standard MSAL isNAAMode = false; const app = new PublicClientApplication(msalConfig); await app.initialize(); return app; } } What’s happening here? supportsNestedAppAuth: true flags the config as NAA-capable. Without it, nothing brokers. createNestablePublicClientApplication tries to hook into the host’s broker. Inside Outlook mobile, it succeeds. In a plain browser tab or an older host, it throws. The catch is the desktop path. Same fallback philosophy as the Graph post: don’t ask “am I on mobile?”, try the capability and let the failure route you. One codebase, two auth worlds, zero platform sniffing. Small but important: initialize this lazily, after Office.onReady() has resolved. The broker hookup needs the Office context to actually be there. Getting a Token: Let the Host Do the Remembering In classic MSAL you manage accounts yourself: call getAllAccounts(), pick one, pass it to acquireTokenSilent. In NAA mode you don’t have to, the broker owns the accounts. You just give it a hint about who you expect: function getLoginHint(): string | undefined { return Office.context?.mailbox?.userProfile?.emailAddress ?? undefined; } async function getToken(scopes: string[]): Promise { const app = await getMsalInstance(); if (isNAAMode) { try { const result = await app.acquireTokenSilent({ scopes, loginHint: getLoginHint(), }); return result.accessToken; } catch { // Silent failed - interactive fallback } // On mobile this doesn't open a browser popup - // the host hands off to the Microsoft Authenticator app const result = await app.acquireTokenPopup({ scopes }); return result.accessToken; } // Non-NAA (desktop/web): the classic dance - // getAllAccounts() → acquireTokenSilent(account) → popup on interaction_required return getTokenClassic(app, scopes); } What’s happening here? The loginHint is just the signed-in mailbox owner’s email, straight from Office.context.mailbox.userProfile. The broker uses it to resolve the right account, which is exactly the account the user is already signed into Outlook with. No account picker, no cache management. acquireTokenSilent with that hint succeeds almost every time, because of course it does, the user is signed in, that’s how they’re reading their email. When interaction is genuinely needed (first consent, revoked session), acquireTokenPopup doesn’t open a popup on mobile despite its name. The host routes it to Microsoft Authenticator, and the user gets a native sign-in experience that actually works on a phone. The non-NAA branch keeps doing what MSAL apps have always done on desktop. Two branches in one function, and the caller just says getToken(scopes) and never knows which world it’s in. The first time I watched the Authenticator app slide up over Outlook, approve, and slide away with my Graph call succeeding behind it, it felt like cheating. In a good way. 🎉 Gotchas I Hit Along the Way The redirect URI must be SPA type. Adding brk-multihub://... under “Web” or “Mobile and desktop applications” in the app registration silently doesn’t work. It goes under Single-page application, even though nothing about it looks like a page. Origin only. brk-multihub://contoso.com/taskpane fails validation. Trim it to the origin. MSAL.js v3+. createNestablePublicClientApplication doesn’t exist in v2. Check your @azure/msal-browser version before you spend an hour on confusing import errors. No B2C. NAA supports Microsoft accounts and Entra ID work/school accounts. If you’re on Azure AD B2C, this door is closed. Wrapping Up That’s the trilogy done. Three posts, one theme: on mobile you don’t get to demand things, you get to ask and adapt. The manifest post got us in the door: declare a low requirement floor so mobile shows the add-in at all. The Graph fallback post got us the bytes: when the Office.js API isn’t there, fetch the same email from Graph. This post got us the token: when there’s no browser to pop, let Outlook broker the auth and Authenticator handle the interaction. Same shape every time: try the capability, catch the failure, take the other road. Mobile support isn’t one big feature, it’s a stack of small fallbacks that each pretend nothing happened. --- ### The SharePoint CSOM Performance Playbook: Stop Paying for Wasted API Calls Forfatter: Jeppe Spanggaard Dato: 2026-05-20 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-05-sharepoint-csom-performance-playbook/ I’ve spent years writing CSOM code, and I keep seeing the same performance sins in every codebase I review. Including my own older code, which is the humbling part. For a long time, slow CSOM was just annoying. Users waited, someone made coffee, life went on. Then I sat down with a client to calculate the cost of Service Prioritization in SharePoint, where every CSOM call has an actual price tag: USD $1.00 per 1,000 calls. Suddenly all those wasted calls weren’t just slow. They were an invoice. That changed how I write CSOM. I count round trips now, the way you’d count database queries in a hot loop. Over the past year I’ve written several posts about the specific techniques that came out of that habit, and this post ties them together into one playbook. Every ExecuteQuery Is a Road Trip Here’s the mental model that makes all four techniques click: every ExecuteQueryAsync() is a road trip to the SharePoint server. Doesn’t matter if you’re delivering one package or a hundred, the drive takes the same time. Network latency, authentication, server processing - the overhead is per trip, not per operation. So the whole playbook boils down to four questions: Can I deliver more packages per trip? (batching) Can one trip cover several destinations? (CAML joins) Is this trip even necessary? (change detection) Can I avoid a second trip when something goes wrong? (ExceptionHandlingScope, and the taxonomy trick) Let’s take them one at a time. Rule 1: Batch or Suffer The most common sin. A loop that loads items one by one: // ❌ 50 items = 50 round trips = 3-5 seconds foreach (var id in itemIds) { var item = list.GetItemById(id); context.Load(item); await context.ExecuteQueryAsync(); ProcessItem(item); } CSOM happily queues up operations until you call ExecuteQueryAsync(). Around 100 operations per batch is the reliable sweet spot: // ✅ 50 items = 1 round trip = ~200-500ms var items = chunk.Select(id => { var item = list.GetItemById(id); list.Context.Load(item); return item; }).ToList(); await context.ExecuteQueryAsync(); In my testing, that’s a 10x+ improvement for the price of restructuring a loop. It works for reads, writes, deletes, all of it. Full post with the reusable ProcessInChunks helper: Batch Your CSOM Operations Instead of Looping One by One. Rule 2: Join Lists, Don’t Query Them One by One Related data across multiple lists is where round trips multiply quietly. Customers in one list, orders in another, order items in a third, so you write three queries and merge the results in C#. Every multi-item query costs 2 resource units toward your throttling budget, so three lists cost 6 units plus three round trips plus the merging code nobody wants to maintain. CAML supports joins, even though the SharePoint UI never hints at it. One query, one trip, 2 resource units, and the server does the merging. I use CAMLEX instead of raw CAML XML because lambda expressions are readable and the XML it generates is not: var query = Camlex.Query() .Where(x => (string)x["CustomerName"] == "Contoso") // filter on a field 2 lists away! .LeftJoin(x => x["OrderTaskLookUp"].ForeignList(ListGuidOrderTasks)) .LeftJoin(x => x["OrderDetailLookUp"].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders)) .LeftJoin(x => x["CustomerLookUp"].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers)) .ProjectedField(x => x["CustomerName"].List(ListGuidCustomers).ShowField("CustomerName")); var camlQuery = new CamlQuery { ViewXml = query.ToString() }; var items = ordersList.GetItems(camlQuery); That’s 66% fewer resource units than the three-query version, and you can even filter on a value several lists away. The lists must be connected via lookup columns, and ProjectedFields only supports simple field types (text, number, date - not user, choice, or managed metadata fields). Full post with the raw CAML comparison and the field type list: Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX. Rule 3: Did Anything Actually Change? Users are save-happy. They open a form, change nothing, and click save anyway. Automated syncs are worse. When I dug into a typical day of API logs on one project, roughly 70% of our SharePoint update calls weren’t changing anything. SharePoint accepts identical data with a smile and bills you for the privilege. The fix is change detection before the update: var differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: true); if (differences.Any()) { foreach (var (fieldName, newValue) in differences) { listItem[fieldName] = newValue; } listItem.Update(); clientContext.ExecuteQuery(); } // Nothing changed? Do absolutely nothing. The hard part is the comparison itself. SharePoint field types fight you: user fields where only LookupId matters, multi-choice fields that come back in random order, taxonomy values, dates with kind mismatches. Naive oldValue == newValue doesn’t survive contact with any of them, and JSON serialization comparison turned out 3-5x slower than a proper normalizing comparer when I benchmarked both. Full post with the complete comparison engine: SharePoint CSOM: Prevent Unnecessary Updates and API Calls. Rule 4: Let the Server Do the Catching This one came straight out of that Service Prioritization cost analysis. The client’s solution made about 90,000 CSOM calls per month, and 88% of them were EnsureUser calls for users who were already on the site. The classic defensive pattern - always ensure the user before setting a user field - was two round trips per assignment, and the first one was almost always pointless. ExceptionHandlingScope lets you ship try-catch logic to the server and resolve it in one round trip: var scope = new ExceptionHandlingScope(clientContext); using (scope.StartScope()) { using (scope.StartTry()) { // Optimistic: works for users already known to the site listItem["AssignedTo"] = FieldUserValue.FromUser("user@company.com"); listItem.Update(); } using (scope.StartCatch()) { // Fallback: only runs server-side if the try failed var user = clientContext.Web.EnsureUser("user@company.com"); listItem["AssignedTo"] = FieldUserValue.FromUser("user@company.com"); } } clientContext.ExecuteQuery(); // one trip Result for that client: a 75-85% reduction in monthly calls. One honest caveat, and it’s important: the scope reduces round trips, not server requests. Each operation inside still counts toward throttling. The call reduction came from the optimistic-first logic, and ExceptionHandlingScope is what made that logic affordable. Full post with the cost breakdown: SharePoint’s Server-Side Try-Catch: ExceptionHandlingScope. Rule 5: Keep Taxonomy Out of Your ViewFields Taxonomy fields are the slowest thing you can put in a CAML query. On a list where items carried 5 to 50 terms across multiple managed metadata fields, loading a few hundred items took 90-120 seconds. Users literally walked away from their computers. The fix has two parts. First, query the list without the taxonomy fields in ViewFields, which is where most of the cost hides. Then load the taxonomy data separately through FieldValuesForEdit, where SharePoint stores it as a raw string you can parse directly: // Raw format in FieldValuesForEdit: "Label1|GUID1;Label2|GUID2" foreach (ListItem[] chunk in items.Cast().Chunk(100)) { foreach (var item in chunk) { clientContext.Load(item, i => i.FieldValuesForEdit); } await clientContext.ExecuteQueryAsync(); // parse Label|GUID pairs per item } That took the same load from 90-120 seconds down to 25-35 seconds, a 70-75% improvement. Fair warning: this parses an internal, undocumented format. It’s been reliable for me, but test it in your environment and keep the traditional approach as a fallback. Full post with the parser: Load SharePoint Taxonomy Fields Fast With FieldValuesForEdit. Which One First? If your workload is write-heavy (forms, syncs, integrations), start with change detection. It’s the only technique that eliminates entire operations instead of making them cheaper, and it’s invisible to users. If your workload is read-heavy, start with batching. It’s the smallest code change for the biggest win, and the chunking helper is reusable everywhere. And if those reads span related lists, add joins next: they cut resource units, not just latency, which stretches your throttling budget further. ExceptionHandlingScope is for when a specific fallback pattern (like EnsureUser) dominates your call logs. Check your logs first - I only found the 88% figure because I went looking. And the taxonomy trick is a targeted weapon: only reach for it when managed metadata is measurably your bottleneck, because it carries the undocumented-format risk. The good news is they stack. Batched updates that skip unchanged items, with server-side fallbacks, is exactly how my current projects run. Gotchas 100 operations per batch is the sweet spot. CSOM reliably handles around that many. Bigger batches mean bigger payloads and less predictable behavior across environments. Ask me how I know. ExceptionHandlingScope doesn’t reduce throttling pressure by itself. Round trips go down, but every operation inside the scope still counts as a request. The savings come from the optimistic-first logic it enables. Your numbers will differ from mine. The 70%, 75-85%, and 70-75% figures are from real projects, but latency, item complexity, and tenant load all move them. Measure before and after in your own environment. Test under throttling before you trust any of it. Dev Proxy can simulate 429 responses locally so you find out how your batches behave under pressure before production does. The taxonomy trick gives you label and GUID, nothing more. If you need full term paths or custom properties, you’re back to the taxonomy API for those items. Joins need lookup columns and GUIDs. CAML joins only work across lists connected by lookup columns, and referencing lists by GUID instead of title saves you from “list does not exist” surprises. If you’re also downloading files in the same solution, the round-trip mindset applies there too - I’ve covered that in downloading multiple files from SharePoint. Wrapping Up Count your round trips before SharePoint counts them for you. That’s the whole playbook in one sentence: fewer trips (batching), combined trips (CAML joins), no pointless trips (change detection), no second trips (ExceptionHandlingScope), and lighter trips (taxonomy loading). Each technique stands on its own, so grab the one that matches your bottleneck: Batch your SharePoint operations Join multiple lists in one CAML query Prevent unnecessary updates Server-side try-catch with ExceptionHandlingScope Fast taxonomy loading --- ### Why My Outlook Add-in Lies About Its Version (And Yours Should Too) Forfatter: Jeppe Spanggaard Dato: 2026-05-12 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-05-outlook-addin-manifest-requirement-sets-mobile/ I recently built an Outlook add-in. It archives emails and attachments to SharePoint, lets you attach cloud files while composing, the works. It ran great on desktop, great in Outlook on the web. Then I opened Outlook on my phone to test it there. The add-in was gone. Not broken. Not throwing errors. Just… not there. No button, no task pane, nothing. Outlook mobile acted like my add-in didn’t exist. The Mistake That Feels Like the Right Thing Here’s the trap. My add-in uses getAsFileAsync() to grab the full email as a file before uploading it to SharePoint. That API lives in the Mailbox 1.14 requirement set. So the obvious, honest, “do the right thing” move is to declare that in the manifest: Makes sense, right? The manifest should declare what the code needs. That’s what a manifest is for. Wrong. Outlook reads that MinVersion and treats it as a door policy: “You need Mailbox 1.14 to see this add-in at all.” Outlook on iOS and Android supports Mailbox 1.5 (plus a handful of newer APIs cherry-picked on top). 1.5 is less than 1.14, so mobile doesn’t fail gracefully, it doesn’t show a warning, it just never lets your add-in through the door. That’s why my add-in vanished on my phone. The manifest version isn’t describing your code. It’s deciding who gets to see your add-in. The Fix: Degrade the Manifest, Upgrade at Runtime The fix felt dirty the first time I did it: I “degraded” the manifest down to Mailbox 1.5, even though my code is full of 1.13 and 1.14 APIs. This is what my add-in actually ships with: And the same low floor inside VersionOverrides: ... Why 1.5? Because that’s what the minimum viable version of my add-in needs: open a task pane, read the current message. Everything above that is a bonus feature, and bonus features shouldn’t decide whether the add-in exists. One more thing mobile needs, and this one is easy to miss: mobile only renders what you explicitly declare in a MobileFormFactor. Desktop extension points don’t carry over. Low floor + MobileFormFactor, and suddenly the add-in shows up on my phone. 🎉 Now Your Code Has to Keep the Promise Here’s the deal you just made: the manifest promises Outlook almost nothing, so your code can’t assume anything. Every API newer than your floor needs a runtime check before you touch it. Office.js has exactly the tool for this: isSetSupported(). I keep all of mine in one file, platformDetection.ts: export function isMobilePlatform(): boolean { if (typeof Office === 'undefined' || !Office.context?.diagnostics?.platform) { return false; } const hostInfo = Office.context.diagnostics.platform; return hostInfo === Office.PlatformType.iOS || hostInfo === Office.PlatformType.Android || hostInfo === Office.PlatformType.Universal; // Includes mobile web } export function supportsArchive(): boolean { if (typeof Office === 'undefined' || !Office.context?.requirements) { return false; } // getAsFileAsync lives in Mailbox 1.14 return Office.context.requirements.isSetSupported("Mailbox", "1.14"); } export function supportsMultiSelect(): boolean { if (typeof Office === 'undefined' || !Office.context?.requirements) { return false; } return Office.context.requirements.isSetSupported("Mailbox", "1.13"); } What’s happening here? isMobilePlatform() asks the host directly what platform it’s running on via Office.context.diagnostics.platform. Note that Universal covers mobile web too, that one surprised me. supportsArchive() checks for Mailbox 1.14 before the code anywhere near getAsFileAsync() runs. On mobile this returns false, and the UI adapts instead of crashing. supportsMultiSelect() does the same for multi-select (Mailbox 1.13). Desktop users get “archive 20 emails at once”, mobile users get one at a time, and nobody gets an error. Every function starts with a typeof Office === 'undefined' guard, so the same code doesn’t explode if it runs outside Outlook (looking at you, local dev in a plain browser tab). Then the components just ask: if (supportsArchive()) { // Full experience: grab the email with getAsFileAsync and upload it } else { // Degraded experience: hide the feature, or take another route } The pattern scales nicely too. In my add-in, when getAsFileAsync isn’t available on mobile, I don’t hide the archive button, I fall back to fetching the message through Microsoft Graph instead. I wrote a follow-up post about exactly that: Same Email, Different Source: Falling Back to Microsoft Graph on Outlook Mobile. The Contract (Write This on a Sticky Note) Manifest floor = the lowest host you still want to appear in. Requirement sets in code = runtime capability checks, not install-time gates. The manifest decides where you exist. The code decides what you can do once you’re there. Mixing those two up is how add-ins silently disappear from half your users’ devices. Gotchas I Hit Along the Way Your manifest can quietly use newer sets than it declares. My manifest declares 1.5 but wires up an OnMessageCompose launch event, which needs Mailbox 1.12. That’s fine, hosts simply ignore extension points they don’t understand. But it means the manifest validator won’t save you here; you have to know which parts light up where. Mobile only gets what MobileFormFactor explicitly exposes. No MobileFormFactor, no mobile button, even with a perfect 1.5 floor. And as of writing, mobile read mode is basically the only surface you get. Test on an actual phone. The desktop “mobile preview” and your imagination will both lie to you. Sideload it, open a real email on a real device, and watch what happens. Wrapping Up Declaring MinVersion="1.14" because your code uses a 1.14 API feels correct, and it’s exactly how you make your add-in invisible on mobile. Degrade the manifest to the lowest version your core experience needs, add a MobileFormFactor, and let isSetSupported() sort out the rest at runtime. It felt like lying to the manifest at first. It’s not, it’s just answering the question the manifest is actually asking: “What’s the least you need to be useful?” --- ### Remove whitespace from an integrated Power App in a tab Forfatter: Alexander Henkel Dato: 2026-04-30 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2026-04-model-driven-app-remove-whitespace/ Learn how to remove the whitespace (vertical) from an integrated Power App, added to a tab. The challenge If you add a Power App into a Model-driven app in a tab, you will get whitespace vertically, which creates an unnessecary scrollbar Solution We can remove the whitespace by adding a CSS and Javascript file to our solution. Step 1 Add the following code to your solution (Web resources) .Canvas\.CanvasControl > div { height: 400px !important; } This is a CSS file, the height of the section will be 400px. Step 2 Add another file to your solution (this time it is a Javascript file) var Example = window.Example || {}; (function () { // Change height of element this.loadCSS = function () { var path = "../WebResources/ahe_sectionHeight"; var head = window.parent.document.getElementsByTagName("head")[0]; var link = document.createElement("link"); link.rel = "stylesheet"; link.type = "text/css"; link.href = path; link.media = "all"; window.parent.document.head.appendChild(link); }; }).call(Example); Note that “path” is currently named ahe_sectionHeight, you should change yours. I now have the following two web resources. Step 3 In your Model-driven app (on the main form) add an on load event handler. Add the Javascript file and make sure that the settings are as below screenshot. Make sure your function is Example.loadCSS Step 4 Reload your Model-driven app, and the height of the section is now 400 pixels 🚀 ‎ --- ### Technical debt is a lot like gardening Forfatter: Dan Toft Dato: 2026-04-28 URL: https://pinksky.dk/blog/forfatter/dan-toft/2026-04-technical-debt-lot-gardening/ In the world of software development, technical debt is a common challenge that teams face. Technical debt referes to the “cost” of rushing things, and taking shortcuts. Especially when we’re trying to rush things out the door, either because the deadline is tighter than expected, or because change management wasn’t properly handled, and suddenly the budget is blown, and you’re only halfway through the project, and you have to make a choice: do you cut corners, and take on technical debt, or do you ask for more time and money to do it right? The analogy of gardening As I’ve been spending a lot of time in my garden lately, for a variety of reasons, I realized that technical debt, is just like gardening, there are weeds in my lawn, the longer I procrastinate taking care of them, the more they grow, and spread, and the harder it becomes to get rid of them. I could spray them with pesticides, but that’s another shortcuts, that’s not sustainable, I’m now contaminating the soil, and the water, and the air. however but pulling them out by hand, and making sure I get the roots, that’s a lot of work, so I have to make a choice: do I take the easy way out, and spray them with pesticides, or do I put in the time and manual labor. What I should’ve done is to have a regular maintenance schedule, where I check for weeds, maybe once a week, and pull them out before they have a chance to grow and spread. Technical debt is exactly the same if I had a regular code review process, and a culture of quality, I could catch technical debt early on, and address it before it becomes a bigger problem. How to manage technical debt with a gardening mindset So, how do we manage technical debt with a gardening mindset? My perspective is bias, as I’ve spent my entire career in the consulting world, and that’s a place where technical debt tends to be more prevalent, because of the nature of the work, and the way projects are structured,they have a start and and end date, where as if you had your own in-house team, you would have more of a continuous development process, and you would be able to address technical debt as it comes up, rather than letting it build up over time. But even in the consulting world, there are ways to manage technical debt with a gardening mindset, and these are my takes on how you should consider approaching technical debt, and how to manage it effectively: It’s all about responsibility and accountablilty lots of consultants will say things like “oh, they didn’t specifically ask for that”, or “that’s not in the scope of work”, but at the end of the day, if you know that something is going to cause technical debt, and you’re not addressing it, then you’re just as responsible for it as the client is. As a consultant So, if you see a weed in your lawn, or your customers lawn and you know that it’s going to spread, and cause more problems down the line, you have a responsibility to pull it out, even if it’s not technically in your job description. And if you’re not going to pull it out, then you have a responsibility to at least warn the client about it, and make sure they understand the consequences of not addressing it. As a client Make sure you have a frank and honest conversation with your consultants about technical debt, and make sure they understand that you expect them to take responsibility for it, and to address it as it comes up, rather than letting it build up over time. And make sure they feel like they’re part of the team, and that they’re invested in the success of the project, rather than just being a vendor that’s there to do a job, and then leave. If they’re not willing to take that responsibility, then maybe you need to reconsider whether they’re the right partner for you. Conclusion Technical debt when working with consultants is hard, because you need to have a culture of quality, and a mindset of continuous improvement, and that’s not always easy to achieve when you’re working with an external partner, that’s focused on deliverables, timelines, and budgets. Make sure you find a partner that’s invested in the success of not just the project, but also in the long-term health of your organization, and your long term success, and that they understand that technical debt is a real problem, and that they’re willing to take responsibility for it, and address it as it comes up, rather than letting it build up over time. TL;DR Technical debt is a lot like gardening, if you let it build up, it becomes harder and harder to manage, and it can cause a lot of problems down the line. Make sure you keep on top of it, and make sure everyone feels responsible, and accountable, and that you have a culture of quality, and a mindset of continuous improvement, and that you’re working with partners that are invested in your long-term success, and that understand the importance of managing technical debt effectively. --- ### One Big PnP Template or Many Small Ones? Forfatter: Jeppe Spanggaard Dato: 2026-04-25 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-04-pnp-template-sizing-resilience/ Every time I sit down to build a SharePoint provisioning engine, I hit the same dilemma: do I put everything into one big PnP template, or do I split it into many smaller ones — one per feature? Both approaches work. Both have real trade-offs. And for a long time I did not have a strong opinion either way. Then I started testing with Dev Proxy and suddenly the answer became very clear to me. What Is a PnP Provisioning Template? If you have not worked with PnP provisioning before: a PnP Provisioning Template is an XML (or JSON) file that describes the structure of a SharePoint site. It can contain: Lists and libraries — custom columns, views, and settings Content types — site or list content types with their fields Pages and web parts — modern pages, hero sections, text blocks Navigation and branding — site navigation structure, themes, logos You apply a template to a site using the PnP Framework library for .NET, which reads the file and provisions everything described in it. The library handles the order of operations, dependencies between objects, and a lot of edge cases you would rather not think about. The typical entry point looks like this: web.ApplyProvisioningTemplate(template, applyInfo); One call. One template. PnP handles the rest — until throttling enters the picture. The Monolith Approach The simplest way to structure your provisioning is one template that contains everything. All lists, all content types, all pages, all navigation — in a single file. Pros: ✅ Simple to manage — one file, one version, one deployment artifact ✅ Easy to apply — a single ApplyProvisioningTemplate call ✅ PnP handles dependencies between objects internally Cons: ❌ All-or-nothing retry — if anything fails, you restart from the beginning ❌ Long templates take a long time to apply, which means long retries too ❌ Hard to reason about what failed and where The all-or-nothing behavior is the killer. SharePoint throttles aggressively under load, and ApplyProvisioningTemplate does not pick up where it left off. Under a sustained throttle — the kind where even 15 retries at the CSOM call level are not enough to get through — the template application eventually throws a MaximumRetryAttemptedException — a PnP-specific exception signalling that all retry attempts were used up. At that point your outer retry logic has no choice but to start the whole template over from the very first list. The Modular Approach The alternative is splitting your template into multiple smaller files, each representing a logical feature or concern. For example: 01-lists-and-libraries.xml 02-content-types.xml 03-pages.xml 04-navigation.xml You apply them in sequence, and after each one completes successfully you record that fact in memory. If a template fails, you retry only from the failed template — not from the beginning. Pros: ✅ Granular retry — only the failed feature is re-applied ✅ Faster recovery from throttling ✅ Easier to reason about failures (“pages failed, lists are fine”) ✅ Easier to test individual features in isolation Cons: ❌ More files to manage and version ❌ Ordering matters — you need to think about dependencies ❌ Slightly more orchestration code on your end The trade-off is real. Modular means more moving parts. But for resilience, the payoff is significant. Testing With DevProxy — Where It Got Interesting I wanted to put both approaches through their paces under realistic throttling conditions. For that I used Dev Proxy with two plugins: GenericRandomErrorPlugin — injects random 429 responses to simulate SharePoint throttling unpredictably RateLimitingPlugin — caps requests per second to trigger organic throttling at volume With these configured, I ran my provisioning engine against both template structures. The result with the monolithic template: PnPClientContext did its job — it absorbed a lot of 429s silently and kept going. But under sustained throttling, the retries ran out, PnP threw a MaximumRetryAttemptedException, and my outer retry had to restart it from scratch. From list number one. Even though we were deep into page provisioning when it failed. The result with modular templates: the same CSOM-level retry absorbed the same throttling. But when a sustained burst finally exhausted PnP’s retries on the pages template, my retry loop skipped straight to 03-pages.xml — lists and content types had already been marked done in memory and were left alone. This is exactly the kind of thing that looks fine in a happy-path test, but silently destroys your provisioning SLA in production. PnPClientContext: Bump the Retry Count for Provisioning The PnP Framework’s internal CSOM calls already use ExecuteQueryRetry() — an extension method that handles 429 throttling with exponential backoff, respecting the Retry-After header SharePoint returns. You get this for free with a plain ClientContext. The default is 10 retries. If you expect heavier throttling during provisioning — and a large site with lots of lists, content types, and pages is a reasonable place to expect it — you can easily raise that limit by converting to a PnPClientContext: using var siteCtx = CSOMClientFactory.Create(provCtx.NewSiteUrl, credential); var pnpCtx = PnPClientContext.ConvertFrom(siteCtx, retryCount: 15); That is the entire change. The provisioning code picks up the higher retry count automatically. Your outer retry logic only comes into play if throttling is so sustained that even 15 attempts on a single CSOM call are not enough — at which point PnP throws a MaximumRetryAttemptedException. Idempotency Strategy The key property that makes modular templates safe to retry is idempotency — applying a template that has already been applied should not cause errors or duplicate data. PnP handles most of this for you: before creating a list, content type, or page, it checks whether it already exists. A very simple example of how this looks in practice: foreach (var source in config.Templates) { var template = LoadTemplate(source); pnpCtx.Web.ApplyProvisioningTemplate(template, applyInfo); } If this loop is re-invoked after a failure (for example by an Azure Function retry policy), templates that already completed will be re-applied — but because each template is idempotent, that is safe. The already-provisioned lists and pages are left untouched. The only cost is the time it takes to re-apply them. That wasted time is the whole point. With a monolithic template, a retry means re-applying everything from scratch. With modular templates, it means re-applying a small number of already-done features before reaching the one that actually failed. Comparison One Big Template Many Small Templates Retry granularity Entire template restarts Only failed feature retries Restart cost High — full re-apply Low — one feature re-applies Complexity Low — one file, one call Medium — ordering, orchestration Idempotency needed Once per template Once per template (still needed) DevProxy testability Easy to test the whole flow Easier to isolate a specific feature Best for Simple sites, low throttle risk Complex sites, production resilience Verdict Neither approach is universally correct. If you are provisioning simple sites with low template complexity and you are not worried about throttling, a monolithic template is perfectly fine and a lot less work to maintain. But if you are building a production provisioning engine that needs to be resilient — where throttling is a real risk, where retries need to be efficient, and where you care about how long a failure takes to recover from — modular templates win clearly. The combination that works best for me is: modular templates + PnPClientContext with conservative retry settings. PnPClientContext handles the low-level CSOM throttling so most 429s never surface at all. When they do surface (and eventually they will), modular templates ensure your retry loop does the minimum amount of work to get back on track. Conclusion The biggest takeaway for me is not even the monolith-vs-modular debate. It is that I never would have found this out without DevProxy. Happy-path testing showed no difference between the two approaches. It was only when I introduced realistic throttling that the behavior diverged in a way that actually matters. If you are building any kind of SharePoint automation that makes CSOM or Graph calls under load, DevProxy should be a standard part of your testing setup. It is free, it integrates with your local development environment, and it reveals exactly the kind of subtle failure modes that only appear in production. References PnP Framework on GitHub Dev Proxy overview SharePoint throttling and retry guidance PnPClientContext — ExecuteQueryRetry docs --- ### SharePoint News Links via Graph SDK: Filling in the Gaps the Docs Left Behind Forfatter: Jeppe Spanggaard Dato: 2026-03-03 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-03-graph-beta-sdk-news-link-with-banner-image/ The Problem With the Documentation News Link pages in SharePoint — those cards that link to external news articles — are only available through the Graph API’s beta endpoint. That’s fine, beta APIs are part of life. What’s not fine is that Microsoft’s official documentation covers the simple case well, but the moment you want to add a banner image, the C# code snippet disappears and is replaced with: “Snippet not available.” Great. Thanks. The banner image upload requires a multipart request. Figuring out how to do that with the Graph Beta SDK means piecing together documentation about MultipartBody, some Kiota internals, and a few quirks you’ll only discover by actually trying it. This post covers the full working solution. Prerequisites Install these two NuGet packages: Microsoft.Graph.Beta Microsoft.Kiota.Serialization.Multipart Microsoft.Graph.Beta is the Beta SDK — it contains NewsLinkPage and all the beta models. Microsoft.Kiota.Serialization.Multipart provides the MultipartBody class needed to construct multipart requests. Without it, you don’t have the types to send binary data alongside the JSON payload. The Simple Case: No Banner Image If you just want a News Link without a banner image, the Beta SDK fluent API handles it cleanly: var page = new NewsLinkPage { OdataType = "#microsoft.graph.newsLinkPage", Title = "Contoso Unveils First Self-Driving Car", NewsWebUrl = "https://someexternalnewssite.com/article", Description = "A brief description of the article." }; var result = await GraphClient.Sites[siteId].Pages.PostAsync(page, requestConfiguration => { requestConfiguration.Headers.Add("prefer", "include-unknown-enum-members"); }); Two things to note: The Prefer: include-unknown-enum-members header is required. Without it, the API won’t return newsLink as a valid pageLayoutType value — it’s an evolvable enum that hasn’t been promoted to v1.0 yet, so Graph treats it as unknown by default. The page is also created as a draft. You still need to publish it separately before it shows up in the news feed. The Full Solution: With Banner Image Here’s the complete implementation including banner image upload and publishing: internal async Task CreateNewsLink( string siteId, string? title, string? url, string? description, Stream? bannerImageContent) { var page = new NewsLinkPage { OdataType = "#microsoft.graph.newsLinkPage", Title = title, NewsWebUrl = url, Description = description, AdditionalData = new Dictionary { ["@microsoft.graph.bannerImageWebUrlContent"] = "name:content" } }; var multipartBody = new MultipartBody(); multipartBody.AddOrReplacePart("request", "application/json", page); multipartBody.AddOrReplacePart("content", "image/jpeg", bannerImageContent, fileName: "banner.jpg"); var requestInfo = new RequestInformation { HttpMethod = Method.POST, UrlTemplate = $"https://graph.microsoft.com/beta/sites/{siteId}/pages" }; requestInfo.Headers.Add("prefer", "include-unknown-enum-members"); requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, "multipart/form-data", multipartBody); var response = await GraphClient.RequestAdapter.SendAsync( requestInfo, NewsLinkPage.CreateFromDiscriminatorValue ); var createdNewslink = await GraphClient.Sites[siteId].Pages[response.Id].GetAsync( requestConfiguration => { requestConfiguration.Headers.Add("prefer", "include-unknown-enum-members"); } ); var publishRequestInfo = new RequestInformation { HttpMethod = Method.POST, UrlTemplate = $"https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish" }; await GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo); } It looks like a lot, but each part has a specific reason for being there. Let me explain the non-obvious bits. Breaking Down the Code The @microsoft.graph.bannerImageWebUrlContent Annotation AdditionalData = new Dictionary { ["@microsoft.graph.bannerImageWebUrlContent"] = "name:content" } This is the glue between the JSON part and the image bytes. The value "name:content" tells the Graph API: “find the image bytes in the multipart part named ‘content’”. When the API processes the request, it reads this annotation, locates the content part in the multipart body, saves the image to the site’s assets library, and sets the bannerImageWebUrl property on the created page. The naming has to match — the part you add with AddOrReplacePart("content", ...) is what the annotation references. Why MultipartBody Instead of the Fluent API The normal fluent API — GraphClient.Sites[siteId].Pages.PostAsync(...) — only supports JSON payloads. There’s no overload that accepts binary data or constructs a multipart request. MultipartBody fills that gap. You compose the request from named parts, each with their own content type: var multipartBody = new MultipartBody(); multipartBody.AddOrReplacePart("request", "application/json", page); multipartBody.AddOrReplacePart("content", "image/jpeg", bannerImageContent, fileName: "banner.jpg"); The "request" part carries the JSON, the "content" part carries the image bytes. The names are what you reference in @microsoft.graph.bannerImageWebUrlContent. Why RequestInformation Directly Since the fluent API can’t send multipart requests, we drop down one level to RequestInformation — the underlying request abstraction that all Kiota-generated clients use internally: var requestInfo = new RequestInformation { HttpMethod = Method.POST, UrlTemplate = $"https://graph.microsoft.com/beta/sites/{siteId}/pages" }; requestInfo.SetContentFromParsable(GraphClient.RequestAdapter, "multipart/form-data", multipartBody); SetContentFromParsable serializes the MultipartBody and sets the correct Content-Type header — including the boundary parameter that multipart requests require. This is one of those things you have to discover by reading the Kiota source code rather than the docs. The Extra GET After Creation You might notice the code fetches the page again right after creating it: var createdNewslink = await GraphClient.Sites[siteId].Pages[response.Id].GetAsync(...); This is a quirk of the multipart POST. The response from a raw RequestInformation-based call doesn’t go through the same deserialization pipeline as the fluent API, which means the returned NewsLinkPage object isn’t fully populated. Rather than fighting with it, a quick GET on the newly created page — with the proper prefer header — gives you a cleanly deserialized object with the correct Id to use for publishing. Publishing the News Link Pages are created as drafts. To make the News Link appear in the SharePoint news feed, you have to explicitly publish it: var publishRequestInfo = new RequestInformation { HttpMethod = Method.POST, UrlTemplate = $"https://graph.microsoft.com/beta/sites/{siteId}/pages/{createdNewslink.Id}/microsoft.graph.newsLinkPage/publish" }; await GraphClient.RequestAdapter.SendNoContentAsync(publishRequestInfo); Again, RequestInformation directly — the Graph Beta SDK’s fluent API doesn’t expose a typed publish method for newsLinkPage. What About the v1.0 SDK? Not supported yet. The NewsLinkPage type, the pageLayout: newsLink enum value, and the publish endpoint are all beta-only. When the API graduates to v1.0, the approach should be almost identical — just swap Microsoft.Graph.Beta for Microsoft.Graph. Until then, you’re on beta. Microsoft officially cautions against using beta APIs in production, but in practice this particular API has been stable for a while. Use your own judgment. --- ### Create Microsoft 365 Groups Without the Welcome Email Flood Forfatter: Jeppe Spanggaard Dato: 2026-02-14 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2026-02-graph-sdk-create-group-without-welcome-email/ The Problem When building automated site provisioning, every Microsoft 365 group creation triggers a welcome email to each member by default. In an automated scenario where multiple groups are created at once, that quickly turns into a flood of emails your users didn’t ask for — and they’re going to call it spam. Microsoft documents WelcomeEmailDisabled as a supported option, but doesn’t show you how to actually set it from the Graph SDK in C#. That’s what this post is about. The Weird Part: AdditionalData If you look at the typed Group object in the SDK, you won’t find a ResourceBehaviorOptions property anywhere. It has to be set through AdditionalData — a catch-all dictionary for properties that exist in the Graph API but aren’t modeled as first-class typed properties in the SDK. It works fine, but it does mean you lose IntelliSense and compile-time safety. The same pattern applies to owners@odata.bind and members@odata.bind, which let you assign owners and members at creation time without separate follow-up calls. The Solution Here’s the complete group creation request with welcome emails disabled, owners set, and members added — all in a single API call: var ownerId = $"https://graph.microsoft.com/v1.0/users/{ownerObjectId}"; var groupMembers = new List { $"https://graph.microsoft.com/v1.0/users/{memberObjectId1}", $"https://graph.microsoft.com/v1.0/users/{memberObjectId2}" }; Group requestBody = new() { Description = description, DisplayName = displayName, GroupTypes = ["Unified"], MailEnabled = false, MailNickname = siteName, SecurityEnabled = true, AdditionalData = new Dictionary { { "resourceBehaviorOptions", new List { "WelcomeEmailDisabled" } }, { "owners@odata.bind", new List { ownerId } }, { "members@odata.bind", groupMembers } } }; Group? group = await _graphClient.Groups.PostAsync(requestBody); resourceBehaviorOptions resourceBehaviorOptions controls specific group behaviors at creation. WelcomeEmailDisabled simply stops Microsoft 365 from sending the welcome email to members. You can also combine multiple options in the same list if needed: { "resourceBehaviorOptions", new List { "WelcomeEmailDisabled", "HideGroupInOutlook" } } Other supported values are documented here. owners@odata.bind and members@odata.bind The @odata.bind syntax binds users to the group by their full resource URL at creation time, so you don’t need separate POST /groups/{id}/members calls afterward. The URL format must be the full Graph v1.0 path: https://graph.microsoft.com/v1.0/users/{objectId} --- ### Load SharePoint Taxonomy Fields Fast With FieldValuesForEdit Forfatter: Jeppe Spanggaard Dato: 2025-12-21 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-12-csom-taxonomy-performance-optimization/ When Fast Wasn’t Fast Enough I was working on a SharePoint solution where we had a list with items containing multiple taxonomy fields. Each item could have anywhere from 5 to 50 taxonomy terms across different fields. The client needed to load hundreds of these items regularly. The traditional CSOM approach was… well, let’s just say coffee breaks became very popular during load times. The loading process was taking 90-120 seconds for a few hundred items. Users were literally walking away from their computers while waiting for data to load. That’s when I realized we needed to completely rethink how we approach taxonomy loading in CSOM. The Traditional (Slow) Way Here’s what most developers do, and what I was doing initially: // The traditional approach - including taxonomy fields in ViewFields CamlQuery query = new CamlQuery(); query.Query = @" "; ListItemCollection items = list.GetItems(query); clientContext.Load(items); await clientContext.ExecuteQueryAsync(); // Then access taxonomy fields normally foreach (ListItem item in items) { var categoryField = item["CategoryField"] as TaxonomyFieldValueCollection; var tagField = item["TagField"] as TaxonomyFieldValueCollection; // Process taxonomy values... } Problems with this approach: Including taxonomy fields in ViewFields is extremely slow SharePoint has to load and process all taxonomy data upfront Multiple expensive taxonomy service calls during the initial load No control over how taxonomy data is loaded Result: Coffee break time. Lots of it. The Optimization Journey After digging into SharePoint’s internals and some experimentation, I discovered two key things: Including taxonomy fields in ViewFields is what kills performance. SharePoint has to load and process all taxonomy data during the initial query, which is extremely slow. SharePoint stores taxonomy data in a raw format that can be accessed via FieldValuesForEdit and parsed directly. This bypasses the expensive taxonomy service calls entirely. The breakthrough: Separate regular field loading from taxonomy field loading. Load items with only the fields you need immediately, then load taxonomy fields separately using the faster FieldValuesForEdit approach. Key Insights: Exclude taxonomy fields from ViewFields - This is the biggest performance gain. Load regular fields first, taxonomy fields separately. FieldValuesForEdit contains raw taxonomy data in the format: Label|GUID;Label|GUID - much faster to parse than TaxonomyFieldValue API Batch loading is crucial - load all items’ FieldValuesForEdit in one call per chunk Chunking prevents timeouts - process items in manageable batches Dictionary lookups are fast - map terms back to items efficiently The Optimized Solution Here’s the approach that cut our load times by 70-75%: Main Loading Logic public async Task?> GetItemsByOptions(string[] options) { // Step 1: Load list items WITHOUT taxonomy fields in ViewFields // This is much faster than including taxonomy fields in the initial query List list = clientContext.Web.Lists.GetByTitle("YourListName"); CamlQuery query = new CamlQuery(); query.Query = @" "; ListItemCollection items = list.GetItems(query); clientContext.Load(items); await clientContext.ExecuteQueryAsync(); if (items == null || items.Count == 0) return null; // Step 2: Process in chunks and load taxonomy fields separately List result = new List(); foreach (ListItem[] chunk in items.Cast().Chunk(100)) { // Load taxonomy fields separately using FieldValuesForEdit var categoryTerms = await LoadTaxonomyField(chunk, "CategoryField"); var tagTerms = await LoadTaxonomyField(chunk, "TagField"); // Step 3: Map everything together foreach (var item in chunk) { var dto = new MyItemDTO { Id = item.Id, Title = item["Title"]?.ToString(), // Map regular fields normally }; // Add taxonomy terms from our separate loading if (categoryTerms.TryGetValue(item.Id.ToString(), out var terms)) dto.CategoryTerms = terms; if (tagTerms.TryGetValue(item.Id.ToString(), out var tags)) dto.TagTerms = tags; result.Add(dto); } } return result; } Batch Taxonomy Loading private async Task>> LoadTaxonomyField( IEnumerable items, string taxonomyFieldInternalName) { // Load FieldValuesForEdit for all items in one batch foreach (var item in items) { clientContext.Load(item, i => i.FieldValuesForEdit); } await clientContext.ExecuteQueryRetryAsync(); // Get the field ID for internal format matching var list = clientContext.Web.Lists.GetById(listGuid); var field = list.Fields.GetFieldByInternalName(taxonomyFieldInternalName); clientContext.Load(field); await clientContext.ExecuteQueryRetryAsync(); string fieldId = field.Id.ToString(); // Parse taxonomy data from each item's FieldValuesForEdit return items.ToDictionary( item => item.Id.ToString(), item => ParseTaxonomyFromEditValues(item, fieldId) ); } private List ParseTaxonomyFromEditValues(ListItem item, string fieldId) { // Find the correct field key (SharePoint uses shortened GUIDs) var fieldValues = item.FieldValuesForEdit.FieldValues .Where(x => x.Key.EndsWith(fieldId.Replace("-", "").Substring(4))); if (!fieldValues.Any()) return new List(); string actualFieldKey = fieldValues.First().Key; if (!item.FieldValuesForEdit.FieldValues.ContainsKey(actualFieldKey)) return new List(); // Parse the raw taxonomy string string rawTaxonomyData = item.FieldValuesForEdit[actualFieldKey]; return ParseTaxonomyEditString(rawTaxonomyData); } Raw Format Parser private List ParseTaxonomyEditString(string editString) { if (string.IsNullOrWhiteSpace(editString)) return new List(); // Format: "Label1|GUID1;Label2|GUID2;Label3|GUID3" return editString .Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) .Select(entry => { var parts = entry.Split('|'); if (parts.Length == 2 && Guid.TryParse(parts[1], out Guid guid)) { return new TaxonomyTerm { Label = parts[0].Trim(), TermGuid = guid }; } return null; }) .Where(t => t != null) .Select(t => t!) .ToList(); } public class TaxonomyTerm { public string Label { get; set; } public Guid TermGuid { get; set; } } Performance Impact The results were dramatic: Before optimization: 90-120 seconds for a few hundred items with multiple taxonomy fields Multiple ExecuteQueryRetryAsync() calls per item Heavy taxonomy service usage After optimization: 25-35 seconds for the same dataset ~70-75% performance improvement Minimal API calls (2 per chunk: one for items, one for field metadata) The exact savings depend on the number of items and terms, but the pattern held consistently across different datasets. Should You Try This Approach? If you’re experiencing slow loading times with taxonomy fields, this might be worth testing. I can’t give you specific numbers for when this optimization makes sense - every environment and scenario is different. What I can tell you is that in my case, it made a dramatic difference. My recommendation: If your current taxonomy loading is painfully slow, try this approach in a test environment Measure the before and after performance in your specific scenario Keep the complexity vs. benefit trade-off in mind Always have a fallback to the traditional approach Remember the limitations: This is an undocumented approach that works with SharePoint’s internal formats You only get basic term information (label + GUID) You’ll need to test thoroughly in your environment The traditional CSOM approach works fine for many scenarios. But if you’re hitting performance walls with taxonomy fields, this optimization might be exactly what you need. Important Disclaimers ⚠️ This is an undocumented approach that relies on SharePoint’s internal storage format for taxonomy fields. While it has worked reliably in my experience, it’s not officially supported by Microsoft. Key Takeaways Batch operations are crucial - Load multiple items’ data in single API calls Chunking prevents timeouts - Process large datasets in manageable pieces Raw formats can be faster - Sometimes bypassing official APIs improves performance Know when to optimize - Don’t add complexity unless you really need the performance Document your risks - Be transparent about using undocumented approaches The traditional CSOM taxonomy approach works fine for simple scenarios. But when you’re dealing with lots of items and lots of terms, sometimes you need to think outside the box. Fast taxonomy loading is one of five techniques in The SharePoint CSOM Performance Playbook, which is where I’d start if taxonomy isn’t the only thing slowing you down. I demoed this approach on the Microsoft 365 & Power Platform community demos call in January 2026, under the same title as this post. Those calls are worth an hour of your month if you build on this stack. --- ### Get all Power Automate flows in an environment in a spreadsheet Forfatter: Alexander Henkel Dato: 2025-12-21 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-12-powerautomate-get-all-flows/ Learn how to get a list of all Power Automate flows (as an admin) The challenge From admin.powerapps.com we can get a list of all flows in our environment, but there is no export button, and you have to click load more to get a full list. Microsoft has also build a Power BI report, but it only shows the flows that has run the last 30 days. Solution We can use Power Automate to create a list of all flows, and export it into a spreadsheet. Note that you need to have Power Platform admin permissions Step 1 Create a new instant cloud flow and select Manually trigger a flow Insert the action List Flows as Admin (V2) and sign in with the Authentication Type: Microsoft Entra ID Integrated Select the environment you would like to get an overview from. Important step, don’t miss this Click on the 3 dots (…) and go to settings Turn on Pagination and set the Threshold to 1000, otherwise you won’t get all flows. You can choose to do this later on if you would like to test your flow first with the top count (ex. get the first 10 flows) Step 2 Insert an apply to each action and insert the value field from your List Flows as Admin (V2) output. The action does not give us the name of the person who owns the flow, but only an ID. There is also a chance that the owner does no longer work for your company, and we won’t be able to get any username based on the ID. Insert an Get user profile (V2) and copy poaste below into the User (UPN) @{items('Apply_to_each')?['properties']?['creator']?['objectId']} You can also insert it as an expression: items('Apply_to_each')?['properties']?['creator']?['objectId'] Step 3 Go to your OneDrive for Business and create a new Excel file. Insert the following headers in row 1 FlowName FlowID State CreatedBy CreatedDate In Excel go to Home and click Format As Table, and select My Table has headers Step 4 Back in Power Automate insert a new Add a row into a table action and navigate into OneDrive and the file location Insert the following into the new row action FlowName: @{items('Apply_to_each')?['properties/displayName']} State: @{items('Apply_to_each')?['name']} CreatedBy: @{items('Apply_to_each')?['properties/state']} CreatedBy: @{outputs('Get_user_profile_(V2)')?['body/displayName']} CreatedDate: @{formatDateTime(item()?['properties']?['createdTime'], 'dd-MM-yyyy')} We can run our flow now as it is, but in case we can’t find the owner of the flow, we want to handle that as well. I also found that some of the flows are from managed solutions (like Copilot Studio Kit etc.) Take a copy of your add a row into a table action and above that action click the + sign and select add a parallel branch Select the 3 dots (…) and click configure run after and unselect is successful and select the other 3 Change CreatedBy to userType instead @{items('Apply_to_each')?['properties/creator/userType']} We can now run the flow. If everything is setup correctly we will now have all the flows available in a spreadsheet ‎ --- ### How your lack of training is making your Copilot adventure more expensive than not investing in AI Forfatter: Dan Toft Dato: 2025-12-19 URL: https://pinksky.dk/blog/forfatter/dan-toft/2025-12-lack-training-making-copilot-adventure-expensive-investing-ai/ This is not my typical type of post, usually my posts are focused on cold hard facts, and developer technology, but a post that I felt I needed to write nonetheless, consider it more of an opinion piece. In my day-to-day work I help a lot of clients implement Microsoft 365, and get a grasp of their Information Architecture, both using just OOTB (Out Of The Box) SharePoint and Teams, but also loads and loads of custom extensibility, all with the purpose of doing more, in less time and I’ve spent almost a decade doing so, but these past few years, something has changed in the world of IA. The age of AI Now I’m sure none of you’ve missed it, but, back in November 2022 OpenAI launched ChatGPT, a revolution in the way we thought about computers, suddenly I could chat with my computer, and get very detailed, long, almost human-like responses, the potential was VERY promising. And it didn’t take long for big tech to catch on, Google with Gemini, Microsoft with Copilot, Meta with Llama and so on, the promise was big! - AI with access to all the knowledge in your organization, what could go wrong? Fast forward almost three years, it’s now late 2025, and everyone has access to some sort of AI, and we’ve all learned to spot AI generated text, if not by the sometimes a bit odd formatting, em dashes, or emoji usage, then by the just waaay too long/detailed answers, or the fact that your insurance advisor who you’re chatting with, and that is definitely a human being, happily provides you a pancake recipe if you ask for it. So have we become more efficient? Well, that’s a tough one, I know loads of people who feel more efficient, and as a matter of fact, I would dare to say, that Github Copilot, has probably made me 10-20% more efficient, and I know I’m not alone. But perceived efficiency does not mean actual efficiency, if you’re shipping things off faster than before, but your colleagues are now spending more time having to correct your work, have we really become more efficient, or have we just gotten faster at producing more? The dark side of AI While AI helps us produce more, faster, something that I see more and more, is that people confuse more with better, combine that with a common misconception that an AI cannot “lie” about the information it’s grounded in, is costing organizations around the world tons of money! The problem isn’t the AI, and it’s not the people, it’s a lack of understanding of how the technology works. And I’m not saying that “Henry from accounting” or “Simone from engineering” needs to understand AI, and what an orchestrator is, or the difference between grounding, and training a model is, HECK, they don’t even need to know what an LLM is. But they do need to understand that the chatbot is not human, and is not actually capable of reasoning, even if you press the reasoning button, YES it does provide a lot of value, if, AND ONLY IF, you make sure to review the outputs, and actually view them through a critical lens, understand what it says, don’t just copy-paste it. I’ve seen an uptick in obviously AI generated things that are costing organizations a ton of money, from project requirements that include obscure things, to sales proposals that don’t match what your client is requesting, I’ve seen requirements ranging from “should work, and be translated to any language” for an organization of just a few Danish folks, to “Any extensibility in SharePoint must be built using PCF” (Power Apps component framework) - these are the kind of things that will throw people on the other end off, now I need to call you, and tell you that what you’re requesting isn’t feasible. Heck I’ve even had clients send me a bit of code, telling me to “run this against our environment” - however upon inspection, said code would slowly delete every file in their tenant. Now most of the above examples are code related, but the same thing goes for your organization’s communication, I recently got added to a Project Plan to help out with a marketing initiative, where the first task was to build out a SWOT report, and the second step was to identify the global target audience … this was for a small local shop, this was just because someone had found the Project Manager agent and thought “huh, this looks professional” and then forwarded a list of 100+ tasks to everyone in the team, that’s now wasting everyone’s time. In this case, my time is billable, which becomes an expense because the employee didn’t bother reviewing what the agent had provided before just blindly forwarding it. And that’s my biggest point in all of this AI Without human in the loop, will cost you, not just money, but also business So what should we do? ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION, ADOPTION. Adoption is the only solution, you really need to make sure that your end users understand the importance of being critical of what an AI outputs, and learn to not blame the AI, an AI is a text generator, it cannot be faulted, only the human that forwards its outputs can be blamed. The excuse “But Copilot told me” is not an excuse for anything short of laziness - and if you’re technical, it’s not an excuse at all. TL;DR AI is just math and text - we all need to understand that human in the loop and agentification is where the actual value of AI is, without it, you just have a random text generator. In essence, AI used without any guardrails, or consideration just improves the quantity of work you do, not the quality, I’m not saying AI can’t help us produce better quality, but to get it to do so takes work! My key takeaways are: AI does not necessarily improve the quality, but rather the quantity. “The old saying “Garbage in, garbage out” still applies. AI with the proper training, and understanding can help improve quality, but it takes work. AI without propper training, grounding/agentification and human in the loop will just cost you money. AI Can and will revolutionize and improve the way we work, but right now it can be more of a productivity drain than a productivity gain without the right guardrails, setup and user education. --- ### Integrating SharePoint Forms and Lists Forfatter: Dan Toft Dato: 2025-12-16 URL: https://pinksky.dk/blog/forfatter/dan-toft/2025-12-integrating-sharepoint-forms-and-lists/ “Recently”, Microsoft introduced list forms, a new and “revolutionary” way to collect data in SharePoint Lists. - It’s still pretty basic, but it’s certainly better than the default list forms. Today, however, I had an interesting question, ‘Is it possible to have the new button on a SharePoint List link to a list form instead of the default form?’ The challenge This customer was very happy with their current SharePoint List setup, but they wanted to improve the data entry experience and had created a beautiful list form that they were leveraging and linking to from other places. BUT They had users creating “weird” data, that didn’t match the “branching” they had in their form, leading to confusion and bad data. It turns out, some users would just click “New” on the list, and fill out the default form, which didn’t have any of the logic or structure of the custom form. The solution Linking the “New” button to a custom list form is not possible out-of-the-box, but there is a workaround. Using either PnPjs or PnP PowerShell, we can set a custom “New” button url, that points to the custom list form. This url property is called NewFormUrl, and as it turns out, if it’s set to a relative URL within your tenant, SharePoint will link to that url when the “New” button is clicked. So it’s as simple as this PnP PowerShell $Ct = Get-PnPContentType -List "123" -Identity "Item" $Ct.NewFormUrl = "/:l:/s/DocSets/JABVaIEpBT3NTrh6YzROxxsAAS-tr5zY8X8T435ZFIMycdY?nav=ODVjNDNjNWQtMmMzMS00NGY2LWFjMmMtOWEwMTBhMzA5YTdm"; $Ct.Update($false); Invoke-PnPQuery; PnPjs await sp.web.lists.getByTitle("123") .contentTypes.getById("0x01001871C4E8CBE98F48875D0412653A801400FDA6EE41FDDA46458FB429349EB3E892") .update({ NewFormUrl: "/:l:/s/DocSets/JABVaIEpBT3NTrh6YzROxxsAAS-tr5zY8X8T435ZFIMycdY?nav=ODVjNDNjNWQtMmMzMS00NGY2LWFjMmMtOWEwMTBhMzA5YTdm" }) Rolling back If you want to roll back to the default form, simply set the NewFormUrl property to an empty string. Caveats One thing to beware of is that there is no “navigation” back to the list from the custom form, so users might get “stuck” on the form page. You might want to add some explanatory text on the “thank you” page of the form, unfortunately list forms do not support having hyperlinks added to the end of them at this time. TL;DR You can set a custom list form as the target of the “New” button in SharePoint Lists by updating the NewFormUrl property of the list’s content type to the relative URL of the custom form using PnP PowerShell or PnPjs. --- ### Filter and search the termstore in SharePoint with Power Automate Forfatter: Alexander Henkel Dato: 2025-12-07 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-12-sharepoint-powerautomate-filter-termstore/ Learn how to filter and search the termstore in SharePoint with Power Automate The challenge We want to search for a specific term in the termstore, because we want to add the term to a file/item. We only have the name of the term, but we need to find the ID of the term as well. I used this trick to update a specific termstore column if the user created ex. a document set insite a specific folder. Solution We can use an HTTP action to get the value of the terms. First let’s filter the termstore using just the browser. We can filter the termstore by using the unique ID’s and use the terms Groups Sets Children Step 1 To get to the sets we have available we will copy the ID of the group MyTermStore and filter down to the sets In your browser you can add yourtenant.sharepoint.com/_api/v2.1/termStore/groups/106ee510-eeab-4492-9f86-14194ec48522/sets This will return the sets available Step 2 Now let’s get the children of the set Animals yourtenant.sharepoint.com/_api/v2.1/termStore/groups/106ee510-eeab-4492-9f86-14194ec48522/sets/81a3486d-5c55-4075-b77d-8d58f806cb03/children Step 3 Finally we will search for Snakes yourtenant.sharepoint.com/_api/v2.1/termStore/groups/106ee510-eeab-4492-9f86-14194ec48522/sets/81a3486d-5c55-4075-b77d-8d58f806cb03/children?$filter=labels/any(a: a/name eq 'Snakes') Power Automate We will create a simple flow that will search the termstore with the title that we add to the item. Create a new list and add a single managed metadata column. In Power Automate add an Send an HTTP request to SharePoint action and insert the Uri you just build. We will search for the title of the item that has just been created. _api/v2.1/termStore/groups/106ee510-eeab-4492-9f86-14194ec48522/sets/81a3486d-5c55-4075-b77d-8d58f806cb03/children?$filter=labels/any(a: a/name eq '@{triggerBody()?['Title']}') If we run our flow, we will see that we now get Snakes as our output To update our list item, SharePoint expects the value like this TermstoreName|TermstoreId Since our HTTP action returns an array we will take the first element of our output (to avoid a loop in our flow) In the termstore value column insert an expression (Name) outputs('Send_an_HTTP_request_to_SharePoint')['body/value'][0]?['label']?[0]?['name'] And then add a | character and insert the next expression (Id) outputs('Send_an_HTTP_request_to_SharePoint')['body/value'][0]['id'] Run your flow again and we have now successfully updated the managed metadata field with the termstore value There are other ways to work with the termstore, see my previous post: Filter Term Store with PowerAutomate ‎ --- ### This operation contains a token that does not exist in the current workflow. Forfatter: Alexander Henkel Dato: 2025-12-04 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-12-powerautomate-this-operation-contains-a-token/ What to do when you get this error when copying from one flow to another (modern designer) The issue I had an issue where I wanted to copy and paste an entire scope from one flow to another. The classic designer had issues, since it did not copy my scope completly. The modern designer copied all my actions correctly, but I was left with this error. Invalid parameters This operation contains a token that does not exist in the current workflow. The fix The fix is easy, but I only found it by “accident”. You need to select (one at a time) the actions with errors, and then click on the “back” button (in the modern designer), and Power Automate realise that nothing is really wrong Here is my action after I clicked the back arrow I hope you found this blog post fast, and did not spend 2 hours like me to trying to resolve this error ‎ --- ### Show last refresh date in local time on powerbi.com Forfatter: Alexander Henkel Dato: 2025-11-17 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-11-powerbi-last-refresh/ Learn how to show “latest refreshed on” on a published report in Power BI The challenge To show latest refreshed on date in Power BI you might have found a good solution that works perfect on your desktop. But when the report is published to www.powerbi.com the time is suddenly off by several hours. The reason is that powerbi.com is calculating the local time using UTC. The solution We want to create a new custom query that works on www.powerbi.com and returns the latest refresh date converted to our local date/time (which should also work for summer/winter time) Step 1 Click on transform data Step 2 Click on New Source and select Blank Query Insert below code in Advanced Editor let WinterOffset = 1, SummerOffset = 2, LocalTime = DateTime.LocalNow(), Year = Date.Year(LocalTime), CurrentDate = Date.From(LocalTime), LastDayMarch = #date(Year, 3, Date.DaysInMonth(#date(Year,3,1))), LastSundayMarch = Date.AddDays(LastDayMarch, -Date.DayOfWeek(LastDayMarch, Day.Sunday)), LastDayOctober = #date(Year, 10, Date.DaysInMonth(#date(Year,10,1))), LastSundayOctober = Date.AddDays(LastDayOctober, -Date.DayOfWeek(LastDayOctober, Day.Sunday)), IsSummerTime = CurrentDate >= LastSundayMarch and CurrentDate < LastSundayOctober, Offset = if IsSummerTime then #duration(0, SummerOffset, 0, 0) else #duration(0, WinterOffset, 0, 0), Adjusted = LocalTime + Offset, Formatted = DateTime.ToText(Adjusted, "dd-MM-yyyy HH:mm:ss"), Output = #table({"LastRefresh"}, {{Formatted}}) in Output The WinterOffset and SummerOffSet can be adjusted according to your timezone Step 3 In Power Query click Close and Apply Insert the LastRefresh column into your report. You will notice that the time is off, but this will be OK, since the time will only be “wrong” for the desktop version of the report. Step 4 Upload your report to www.powerbi.com and refresh your symantic model The refreshed time will now be your local timezone ‎ --- ### SharePoint CSOM: Prevent Unnecessary Updates and API Calls Forfatter: Jeppe Spanggaard Dato: 2025-11-05 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-11-sharepoint-csom-prevent-unnecessary-updates/ The Moment Everything Clicked So there I was, building what I thought was a pretty straightforward API endpoint. Users fill out a form, click save, and boom—SharePoint list item gets updated. Simple, right? Wrong. It was during one of those routine maintenance windows when I decided to review some API logs that I noticed something odd. The numbers just didn’t add up. We were making way more SharePoint API calls than seemed necessary for the amount of actual data changes happening in the system. Every operation was hitting SharePoint. Even when nothing had actually changed. That’s when it hit me. Our application was treating every potential update as an actual update, regardless of whether the data was different from what SharePoint already had. Users could open a form, change nothing, click save, and we’d still fire off an API call to “update” the item with identical values. But SharePoint doesn’t care if you’re setting a field to the exact same value it already has. It still counts that as an API call. It still hits your throttling limits. And if you’re paying for “Service Prioritization in SharePoint”? It still costs you money. The “Why Did I Even Build This?” Moment I spent some time digging deeper into the patterns, and the numbers were… enlightening. This wasn’t just a user behavior issue. It was happening everywhere: Users frequently save forms without making actual changes Automated processes periodically “sync” data that’s already current Applications send complete object states rather than just changed fields Integration systems perform routine updates as part of larger workflows When I looked at a typical day’s worth of operations, I found that roughly 70% of our SharePoint update calls weren’t actually changing anything. We were essentially paying SharePoint to accept identical data and politely say “thanks for the update!” The more I thought about it, the more I realized this pattern was probably happening in systems everywhere. How many developers have built the same “just update everything” approach without stopping to ask whether anything actually changed? SharePoint’s “Sure, I’ll Take Your Money” Attitude Here’s the thing about SharePoint’s CSOM that nobody really talks about: it doesn’t care if you’re being wasteful. You want to set a field to the exact same value it already has? “No problem!” says SharePoint. “That’ll be one API call, please.” And if you’re using Service Prioritization in SharePoint? That’s real money walking out the door. Let me show you what I mean with some concrete numbers from a client I worked with recently: The Head-Scratching Cases // These should be considered equal, but .NET says "nope" currentValue: "John Doe" newValue: "John Doe" // Easy case, no problem here currentValue: null newValue: "" // Is empty string the same as null? Your call! currentValue: 42 newValue: "42" // Different types, same value. Fun times. The SharePoint Special Cases // User fields (oh boy...) currentValue: FieldUserValue { LookupId = 15, LookupValue = "John Doe" } newValue: FieldUserValue { LookupId = 15, LookupValue = "John Doe" } // Multi-choice fields that hate you currentValue: ["Option A", "Option C", "Option B"] newValue: ["Option B", "Option A", "Option C"] // Same options, different order // Taxonomy fields (because why not make it complicated?) currentValue: TaxonomyFieldValue { TermGuid = "abc-123", Label = "Technology" } newValue: TaxonomyFieldValue { TermGuid = "abc-123", Label = "Technology" } After an hour of testing different scenarios, I realized I needed something more sophisticated than oldValue == newValue. Building the “Actually Smart” Comparison Engine Alright, time to get serious. I needed a comparison function that could handle all of SharePoint’s quirky field types and still be fast enough to not slow down my API. Here’s what I ended up building (and yes, it’s a bit of a beast): internal static List<(string InternalName, object? NewValue)> GetDifferences( ListItem item, IDictionary newValues, bool treatEmptyStringAsNull) { var diffs = new List<(string, object?)>(); foreach (var kvp in newValues) { var name = kvp.Key; var newVal = kvp.Value; object? currentVal = null; if (item.FieldValues != null && item.FieldValues.TryGetValue(name, out var cv)) { currentVal = cv; } if (!ValuesEqual(currentVal, newVal, treatEmptyStringAsNull)) { diffs.Add((name, newVal)); } } return diffs; } private static bool ValuesEqual(object? a, object? b, bool treatEmptyStringAsNull) { if (ReferenceEquals(a, b)) { return true; } if (a is null || b is null) { if (treatEmptyStringAsNull) { if ((a is null && IsEmptyStringLike(b)) || (b is null && IsEmptyStringLike(a))) { return true; } } return false; } a = Normalize(a, treatEmptyStringAsNull); b = Normalize(b, treatEmptyStringAsNull); if (a is IStructuralEquatable seA && b is IStructuralEquatable seB) { return StructuralComparisons.StructuralEqualityComparer.Equals(seA, seB); } return Equals(a, b); } private static bool IsEmptyStringLike(object? x) { return x is string s && string.IsNullOrWhiteSpace(s); } private static object? Normalize(object? v, bool treatEmptyStringAsNull) { if (v == null) { return null; } switch (v) { case string s: return (treatEmptyStringAsNull && string.IsNullOrWhiteSpace(s)) ? null : s; case bool b: return b; case byte or sbyte or short or ushort or int or uint or long or ulong or float or double or decimal: return Convert.ToDecimal(v, CultureInfo.InvariantCulture); case DateTime dt: var utc = (dt.Kind == DateTimeKind.Utc ? dt : DateTime.SpecifyKind(dt, DateTimeKind.Unspecified)).ToUniversalTime(); return new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, utc.Second, DateTimeKind.Utc); case FieldUserValue u: return u.LookupId; case FieldLookupValue l: return l.LookupId; case IEnumerable multiLookup: return multiLookup.Select(x => x?.LookupId ?? 0).OrderBy(x => x).ToArray(); case TaxonomyFieldValue tx: return tx.TermGuid?.Trim().ToLowerInvariant(); case TaxonomyFieldValueCollection txc: return txc .Where(x => x != null && !string.IsNullOrEmpty(x.TermGuid)) .Select(x => x.TermGuid.Trim().ToLowerInvariant()) .OrderBy(g => g) .ToArray(); case FieldGeolocationValue geo: return new ValueTuple( Math.Round(geo.Latitude, 6), Math.Round(geo.Longitude, 6), Math.Round(geo.Altitude, 2), Math.Round(geo.Measure, 2)); case string[] ss: return ss .Select(x => treatEmptyStringAsNull && string.IsNullOrWhiteSpace(x) ? null : x) .OrderBy(x => x, StringComparer.Ordinal) .ToArray(); case IEnumerable enumerable when v is not string: { var list = new List(); foreach (var e in enumerable) { list.Add(Normalize(e, treatEmptyStringAsNull)); } return list.ToArray(); } default: return v; } } The Magic Behind the Scenes 1. The Normalization Dance The Normalize method is where the real magic happens. It takes SharePoint’s various field types and converts them into something we can actually compare: Numbers: Everything becomes a decimal because SharePoint loves to mix integers and strings DateTime: Converted to UTC and truncated to seconds (because who needs millisecond precision for list items?) User/Lookup Fields: We only care about the LookupId, not the display text that might change Collections: Sorted alphabetically because SharePoint doesn’t guarantee order Strings: Can optionally treat empty/whitespace as null (trust me, you want this) 2. The “It’s Not You, It’s SharePoint” Handling For arrays and complex objects, I use IStructuralEquatable to do deep comparisons. Because yes, SharePoint will absolutely give you arrays that contain the same items but in different orders. 3. The Business Logic Escape Hatch The treatEmptyStringAsNull parameter saved my sanity. Different parts of your application might handle empty values differently, and this lets you define your own rules for what counts as “unchanged.” But Wait, Why Not Just Use JSON Comparison? Before you ask (because I know you’re thinking it): “Why build this elaborate comparison engine when you could just serialize both objects to JSON and compare the strings?” Great question. I actually thought the same thing initially. How hard could it be, right? Just JsonSerializer.Serialize() both the old and new values, compare the resulting strings, and call it a day. Here’s what the “simple” JSON approach looks like: internal static List<(string InternalName, object? NewValue)> GetDifferencesJson( IDictionary oldValues, IDictionary newValues, bool treatEmptyStringAsNull) { var diffs = new List<(string, object?)>(); foreach (var kvp in newValues) { var name = kvp.Key; var newVal = kvp.Value; object? currentVal = null; oldValues?.TryGetValue(name, out currentVal); var newJson = JsonSerializer.Serialize(newVal); var oldJson = JsonSerializer.Serialize(currentVal); if (newJson != oldJson) { diffs.Add((name, newVal)); } } return diffs; } Looks clean and simple, right? That’s what I thought too. So I built both approaches and put them head-to-head with BenchmarkDotNet. The results were… eye-opening. The Performance Reality Check Here’s what I found when comparing the performance of my custom approach versus JSON string comparison: Single List Item Comparison: Custom Dictionary Approach: 749,625 operations/second (1.334 μs per operation) JSON String Approach: 201,109 operations/second (4.972 μs per operation) Performance difference: 3.7x faster with the custom approach Batch Operations (300 items): Custom Dictionary Approach: 3,705 operations/second (269.91 μs per batch) JSON String Approach: 712 operations/second (1,404.06 μs per batch) Performance difference: 5.2x faster with the custom approach Why JSON Comparison Falls Short The numbers tell the story, but here’s what’s actually happening under the hood: Serialization Overhead: Converting SharePoint field values to JSON strings requires multiple allocations and string operations Memory Pressure: JSON approach allocated nearly 50% more memory (4.67KB vs 3.17KB per operation) Type Conversion Issues: JSON serialization doesn’t handle SharePoint’s quirky field types the way we need Garbage Collection: More allocations = more GC pressure = slower overall performance When you’re processing hundreds or thousands of list items in batch operations, that 3-5x performance difference really adds up. Plus, the custom approach gives us complete control over how different field types are compared, which JSON comparison simply can’t provide. Putting It All Together: The Real-World Implementation Here’s how I actually use this in my APIs: public async Task UpdateListItemAsync(int itemId, Dictionary newValues) { var clientContext = GetClientContext(); var list = clientContext.Web.Lists.GetByTitle("YourList"); var listItem = list.GetItemById(itemId); clientContext.Load(listItem); clientContext.ExecuteQuery(); // The moment of truth - what actually changed? var differences = GetDifferences(listItem, newValues, treatEmptyStringAsNull: true); if (!differences.Any()) { // Nothing changed! Skip the update entirely return false; // "Thanks for playing, but you didn't actually change anything" } // Only update the fields that actually changed foreach (var (fieldName, newValue) in differences) { listItem[fieldName] = newValue; } listItem.Update(); clientContext.ExecuteQuery(); return true; // "Something actually happened!" } The Numbers Don’t Lie (And They’re Pretty Great) After implementing this change detection across our applications, here’s what we typically see: Before implementing change detection: Applications making thousands of update requests per day Every single one triggered a SharePoint ExecuteQuery() High API call volumes and occasional throttling issues After implementing change detection: Same number of update requests from users/systems 60-80% contained no actual changes and were skipped Dramatically reduced SharePoint API consumption Much lower throttling risk The typical impact: Significant reduction in monthly API calls Cost savings with Service Prioritization in SharePoint (varies by usage) Reduced throttling risk during busy periods Faster API responses because skipped operations are basically instant But here’s what really matters: the performance improvement isn’t just about the numbers. Users notice when applications feel more responsive, and developers notice when they stop getting throttling alerts. The Gotchas (Because There Are Always Gotchas) When This Approach Might Not Be For You Audit Requirements: If you need to log every single “save” action regardless of whether anything changed, this might not work for your use case. Always Update Timestamps: Some business requirements dictate that “LastModified” should always be updated when a user clicks save, even if the content is identical. Super Simple Scenarios: If you’re only updating one field and it’s a simple string comparison, the overhead of this elaborate comparison might not be worth it. The SharePoint Field Types That Made Me Question My Life Choices Some field types need special handling: // Calculated fields - don't even try to update these if (field.ReadOnlyField || field.FieldTypeKind == FieldType.Calculated) { continue; // SharePoint will just ignore you anyway } // Attachment fields - these need completely different logic if (field.FieldTypeKind == FieldType.Attachments) { // Handle separately - attachments are their own special nightmare continue; } Wrapping Up (And Why This Matters More Than You Think) Look, I know this might seem like over-engineering for a simple “update SharePoint item” operation. But here’s the thing: those small inefficiencies add up fast. In my case, this one change: Improved user experience with faster response times Reduced throttling headaches during busy periods Made me feel like a responsible developer (this one’s important too!) The pattern I’ve shown you isn’t just about SharePoint or just about API optimization. It’s about asking the fundamental question: “Is this operation actually necessary?” That question has made me a better developer across all the platforms I work with, not just SharePoint. So next time you’re building any kind of update operation—whether it’s SharePoint, a database, or that JSON file you’re definitely not using as a database—pause for a moment and ask: “Did anything actually change?” Your future self (and your API bills) will thank you. Pro tip: Start implementing this pattern on your highest-traffic endpoints first. That’s where you’ll see the biggest impact, and it’ll give you the confidence to roll it out everywhere else. Now go forth and eliminate those unnecessary API calls. Your SharePoint environment will purr like a happy cat. Change detection is one of five techniques in The SharePoint CSOM Performance Playbook. If you want the whole picture of where CSOM calls leak, start there. --- ### Converting a Folder to a Document Set Forfatter: Dan Toft Dato: 2025-10-24 URL: https://pinksky.dk/blog/forfatter/dan-toft/2025-10-converting-folder-document-set/ Document Sets are often referred to as “folders on steroids” in SharePoint, as they provide a way to group related documents together, while also providing additional metadata and functionality. However, there may be situations where you have an existing folder that you want to convert into a Document Set to take advantage of these features. While SharePoint does not provide a direct way to convert a folder into a Document Set, it is indeed possible to achieve. Prerequisites First we must ensure that we have Document Sets enabled, and a content type for Document Sets created. PnP PowerShell You’ll need to have PnP PowerShell installed and be able to connect to your SharePoint site where the folder resides. For more information on installing and setting up PnP PowerShell, refer to the official documentation. Enabling Document Sets To enable Document Sets in your SharePoint site, follow these steps: Go to Site Settings on your SharePoint site. Navigate to “Site Collection Features” Activate the “Document Sets” feature. Creating a Document Set Content Type Go back to Site Settings on your SharePoint site. Press “Site content types” Create a new content type by pressing “Create” Name it appropriately, e.g., “My Document Set” Make it inherit from “Document Set” as the parent content type. (it’s under the “Document Set Content Types” group) Press OK to create the content type. (Optional) Add any additional columns or metadata to the Document Set content type as needed. Adding the Document Set Content Type to a Library Next, we need to add the newly created Document Set content type to the document library where the folder resides. Navigate to the document library containing the folder you want to convert. Go to Library Settings. Under “Advanced settings”, ensure that “Allow management of content types” is set to “Yes”. Back in the Library Settings, under “Content Types”, click “Add from existing site content types”. Select the Document Set content type you created earlier and add it to the library. Press OK to save the changes. Converting the Folder to a Document Set Now that we have the Document Set content type added to the library, we can proceed to convert the folder. First we need to grab the content type ID of the Document Set content type we created earlier, this is where things get a bit ’tricky' Jump back into the library settings, and under “Content Types”, click on the Document Set content type you created earlier. In the URL, you’ll see a string piece of text after “ctype=” - it starts with 0x0120D5 - copy this entire string, as this is the content type ID we need for the conversion. Mine was 0x0120D5200015E586766B0CF54B87B393D434B58BE6 - but yours will be different, so make sure you copy it correctly. With the content type ID copied, we can get to converting the folder, I like to use PnP PowerShell for this, as it makes things quite straightforward. # Variables $siteUrl = "https://yourtenant.sharepoint.com/sites/yoursite" $listName = "Documents" # Change to your document library name $docSetContentTypeId = "0x0120D5200015E586766B0CF54B87B393D434B58BE6" # Change to your Document Set content type ID Connect-PnPOnline -Url $siteUrl $list = Get-PnPList $listName -Includes RootFolder.Folders # Get all folders in the library $folders = $list.RootFolder.Folders | Where-Object { $_.Name -ne "Forms" # Exclude system folders } # Loop through each folder foreach ($folder in $folders) { Write-Host "Processing folder: $($folder.Name)" -ForegroundColor Cyan # Get the list item ID from the folder's ListItemAllFields $folderItem = Get-PnPProperty -ClientObject $folder -Property ListItemAllFields if ($folderItem) { $currentContentTypeId = $folderItem["ContentTypeId"] # Check if it's already a Document Set (Content Type ID starts with 0x0120D5) if ($currentContentTypeId -notlike "0x0120D5*") { Write-Host " Converting to Document Set..." -ForegroundColor Yellow # Set the content type to Document Set Set-PnPListItem -List $listName -Identity $folderItem.Id -Values @{ "ContentTypeId" = $docSetContentTypeId } -UpdateType "Update" Write-Host " Successfully converted!" -ForegroundColor Green } else { Write-Host " Already a Document Set, skipping." -ForegroundColor Gray } } } Write-Host "`nConversion complete!" -ForegroundColor Green TL;DR By changing the content type ID of the folder to that of a Document Set, SharePoint treats it as a Document Set, allowing you to leverage all the features and benefits that come with Document Sets. --- ### The curious case of Modern Document Sets Forfatter: Dan Toft Dato: 2025-10-23 URL: https://pinksky.dk/blog/forfatter/dan-toft/2025-10-curious-case-modern-document-sets/ All the way back in May of 2023, I shared a “hack” I’d found to enable Document Sets in SharePoint Online to have a modern experience when being created. Taking the creation experience from the classic SharePoint form this to this Simply setting the value of NewFormClientSideComponentId on the Document Set content type to the null. this is a field used for SPFx custom forms, but when set to null. SharePoint somehow falls back to the modern form experience, which cannot be done out of the box. See the script sample here for more details. Skip to 2025 The year is now 2025, and I still can’t find any official documentation from Microsoft about this feature, nor have I seen any issues, two and a half years later, I, and dozens of others, continue to use this approach without any issues. That was until Friday, October the 10th, when an email came in from Bastiaan Kortenbout, a fellow SharePoint enthusiast, asking if I too had observed issues with Modern Document Sets. As it turns out Bastiaan, was using the method in a configuration/use case I’d never tested. The sorting issue Normally when I’m using Document Sets, they’re the “outer most layer” of my data, so nothing but Document Sets go in the root of a document library. But, as it turns out, Bastiaan was using Document Sets mixed with folders, meaning that in the root of his document library, he had both Document Sets and regular folders, and even regular files. Now nothing is wrong with this approach, but it highlighted an interesting issue with the sorting of Document Sets, as it turns out, normally document sets are sorted among files, and folders kept at the top of the view. But with modern Document Sets, they now behave like folders. That is until you either capture a version, or copy/move them, which somehow makes them sort with files again. This results in a confusing experience for end users, as the Modern Document Sets appear with the folders, while classic Document Sets appear with the files. The fix Bastiaan did some digging, and found that the issue was related to the hidden SortBehavior field, which appears to control the sorting of folders, and files, so he created a quick view including this field, validated this theory. Add-PnPView -List "Document Library" -Title "TestView" -Fields "LinkFilename","SortBehavior" Lo and behold, it all makes sense. Document Sets created using the modern form have a SortBehavior value of 1, which is the same as folders, while classic Document Sets have a SortBehavior value of 0, which is the same as files. Now making changes to this field directly is not supported, as it’s a hidden field, and changing it may have unintended consequences - but what Bastiaan found, is capturing a version, or copy/moving the Document Set, changed the SortBehavior field was reset to 0. Which means that they’re now sorted with files again. Bastiaan and I evaluated the situation, over mail, and Bastiaan came up with two options. Option 1: Change the view settings (preferred) Under the classic view settings, there’s an option to “Sort only by specified criteria (folder may not appear before items)” setting, while it may not be desirable in all cases, it’s probably the best solution in this scenario This does however mean that folders will no longer appear at the top of the view, but mixed with items and Document Sets, so be sure to communicate this to your end users or just make sure you have a separate view for inside your Document Sets, which doesn’t have this flag set. Option 2: Disable modern Document Sets The other, and more nuclear option is to disable the modern Document Set experience the library, using this snippet $docLib = Get-PnPList -Identity "Document Library" $ct = Get-PnPContentType -List $docLib -Identity "Document Set" $ct.NewFormUrl = "_layouts/15/NewDocSet.aspx" $ct.Update($false) Invoke-PnPQuery And then update the SortBehavior field on the modern Document Sets to 0, so they’re sorted with files again, by capturing a version on all of the ones that are still set to SortBehavior set to 1 using this snippet. # Get all Document Sets $docsets = Get-PnPListItem -List $listName -PageSize 500 | Where-Object { $_["HTML_x0020_File_x0020_Type"] -eq "Sharepoint.DocumentSet" } # Filter for those with SortBehavior = 1 $targets = $docsets | Where-Object { $_["SortBehavior"] -and $_["SortBehavior"].LookupValue -eq "1" } Write-Host "Found $($targets.Count) Document Sets with SortBehavior = 1." foreach ($ds in $targets) { $name = $ds["FileLeafRef"] $id = $ds["ID"] try { Write-Host "Processing Document Set: $name (ID: $id)" # Get the list item again to ensure all properties are loaded $listItem = Get-PnPListItem -List $listName -Id $id # Capture a new version $listItem.UpdateOverwriteVersion() Invoke-PnPQuery Start-Sleep -Seconds 2 # Recheck SortBehavior value $updated = Get-PnPListItem -List $listName -Id $id $newSort = $updated["SortBehavior"].LookupValue Write-Host "Version captured. SortBehavior now: $newSort" } catch { Write-Host "Failed for ${name}: $($_.Exception.Message)" } } Now all modern Document Sets appear mixed in with classic Document Sets and files, just like one would expect, out of the box. TL;DR The Document Set modernisation hack from years ago still works, however, there’s a twist, modern Document Sets have a SortBehavior field set to 1, which sorts them with folders, at the top of views, while classic Document Sets have a SortBehavior field set to 0, which sorts them with files. Even more curiously, once you capture a version, or copy/move a modern Document Set, the SortBehavior field is reset to 0, making them sort with files again - causing inconsistency in the user experience, as some Document Sets may appear with folders, while others appear with files. This is all fixable either by changing the view settings to not prioritize folders at the top, or by disabling modern Document Sets, and capturing a version on all modern Document Sets to reset their SortBehavior field to 0. --- ### SharePoint's Server-Side Try-Catch: ExceptionHandlingScope Forfatter: Jeppe Spanggaard Dato: 2025-10-20 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-10-sharepoint-exceptionhandlingscope-csom-ensureuser/ The Service Prioritization in SharePoint Cost Analysis Recently, I was working with a client to calculate the cost of enabling Service Prioritization in SharePoint on one of their app registrations. The pricing model is straightforward: USD $0.50 per 1,000 Graph calls and USD $1.00 per 1,000 SP REST/CSOM calls. During the analysis, I discovered something that caught my attention: their solution was making approximately 90,000 calls per month, and 88% of all their CSOM calls were EnsureUser operations. This was particularly interesting because the users being “ensured” were already associated with the site where the data was being created. It seemed like there should be a more efficient approach - similar to what’s possible with Graph API. The Problem with User Field Assignment If you’ve worked with SharePoint user fields, you’re familiar with this common pattern: // The standard approach var user = clientContext.Web.EnsureUser("user@company.com"); clientContext.Load(user); clientContext.ExecuteQuery(); // Network call #1 listItem["AssignedTo"] = new FieldUserValue() { LookupId = user.Id }; listItem.Update(); clientContext.ExecuteQuery(); // Network call #2 This requires two network calls for each user assignment. Microsoft provides FieldUserValue.FromUser("user@company.com") as an alternative, but it throws an exception if the user isn’t already “known” to the site. This leads to either making two calls to be safe, or handling exceptions with additional calls. Discovering ExceptionHandlingScope While researching optimization approaches, I came across a section in Microsoft’s documentation for “Complete basic operations using SharePoint client library code” about ExceptionHandlingScope. ExceptionHandlingScope allows you to implement try-catch logic that executes on the SharePoint server rather than requiring multiple round trips from your client application. Instead of this painful dance: Try operation → Network call Handle exception → Network call Retry operation → Network call You get this beautiful symphony: Send try-catch logic to server → Network call Server handles everything internally Get result → You’re done Here’s the magic in action: var scope = new ExceptionHandlingScope(clientContext); using (scope.StartScope()) { using (scope.StartTry()) { // Try the optimistic approach first listItem["CaseResponsible"] = FieldUserValue.FromUser("user@company.com"); listItem.Update(); } using (scope.StartCatch()) { // If that fails, ensure the user exists var user = clientContext.Web.EnsureUser("user@company.com"); listItem["CaseResponsible"] = FieldUserValue.FromUser("user@company.com"); } } // Execute once - the server handles all the logic clientContext.ExecuteQuery(); One network call. That’s it. The server receives your entire try-catch block, attempts the optimistic operation first, and only falls back to EnsureUser if needed. No round trips. No guessing. No waste. Real-World Impact: Significant Call Reduction Looking back at the client scenario with ExceptionHandlingScope: Before: ~79,200 EnsureUser calls + ~10,800 actual operations = 90,000 total calls After: ~10,800-21,600 operations (depending on how many users need ensuring) = substantial reduction This represents a potential 75-85% reduction in API calls, with corresponding improvements in both performance and Service Prioritization in SharePoint costs. The Cost Breakdown Let’s translate this into actual Service Prioritization in SharePoint costs: Before optimization: 90,000 CSOM calls per month At USD $1.00 per 1,000 calls Monthly cost: $90 After ExceptionHandlingScope optimization: Best case: 10,800 calls (if most users are already known) = $10.80/month Worst case: 21,600 calls (if many users need ensuring) = $21.60/month Monthly savings: $68.40 - $79.20 Annual savings: $820 - $950 These savings become even more significant at scale. For organizations with multiple solutions or higher call volumes, the cost difference can easily reach thousands of dollars annually. More importantly, the performance improvements—reduced network latency, faster operations, and lower throttling risk—often provide value that far exceeds the direct cost savings. Throttling: The Hidden Performance Killer Beyond cost savings, ExceptionHandlingScope provides significant throttling benefits that are often more impactful than the financial savings. Understanding SharePoint Throttling SharePoint applies throttling limits to prevent abuse and ensure service stability. When you exceed these limits, you’ll encounter: HTTP 429 (Too Many Requests) responses Exponential backoff delays (sometimes minutes) User experience degradation as operations slow down Potential service interruptions during peak usage The Throttling Math In our client scenario: Before ExceptionHandlingScope: 90,000 calls per month = ~3,000 calls per day During peak hours, this could easily trigger throttling Each throttled request requires retry with exponential backoff A single throttled operation can delay your entire batch After ExceptionHandlingScope: 10,800-21,600 calls per month = ~360-720 calls per day 10x reduction in throttling risk Smoother operation during peak business hours More predictable performance for end users Real-World Throttling Impact Consider a typical business day where multiple users are creating items simultaneously: Without optimization: 88% of your throttling budget consumed by redundant EnsureUser calls With ExceptionHandlingScope: 88% of your throttling budget available for actual business operations The beauty of ExceptionHandlingScope is that it doesn’t just reduce the number of calls—it makes your remaining calls more valuable and less likely to be throttled. Important: ExceptionHandlingScope Is NOT a Magic Bullet Critical Disclaimer: ExceptionHandlingScope itself does NOT reduce the number of requests to SharePoint. Each operation within the scope still counts as a separate request for throttling purposes. The reduction in our scenario comes from changing the approach, not from using ExceptionHandlingScope: What Actually Reduces Calls Before: Always calling EnsureUser + setting the field = 2 calls per user After: Using FieldUserValue.FromUser() directly, falling back to EnsureUser only when needed What ExceptionHandlingScope Actually Does ExceptionHandlingScope reduces network round trips, not server requests: Network benefit: 1 round trip instead of potentially 2-3 Throttling impact: Each operation still counts toward throttling limits Performance gain: Reduced latency, not reduced server load The Real Magic The 75-85% reduction in our client scenario comes from this logic change: // This approach reduces actual requests // Most users are already known to the site // So most operations only need 1 request instead of 2 using (scope.StartTry()) { // This succeeds for ~80% of users (1 request) listItem["Field"] = FieldUserValue.FromUser("user@company.com"); } using (scope.StartCatch()) { // This only runs for ~20% of users (1 request) var user = clientContext.Web.EnsureUser("user@company.com"); listItem["Field"] = FieldUserValue.FromUser("user@company.com"); } ExceptionHandlingScope simply makes this pattern efficient by handling the try-catch logic server-side instead of requiring multiple network round trips to determine which approach to use. Bottom line: ExceptionHandlingScope optimizes network efficiency, but the request reduction comes from smarter business logic, not from the scope itself. The Broader Lesson This experience highlighted an important principle: before optimizing an existing approach, it’s worth questioning whether there’s a fundamentally different way to solve the problem. In this case, server-side exception handling provided a cleaner solution than client-side optimization or caching strategies. ExceptionHandlingScope has been available since SharePoint 2013, but it’s not widely discussed in the SharePoint development community. It’s a good reminder to periodically review Microsoft’s documentation for features that might address current challenges in new ways. Your Next Steps Before you write your next SharePoint operation that involves potential exceptions: Pause and calculate: How many network calls will your approach generate? Question the pattern: Could server-side logic handle the complexity? Explore ExceptionHandlingScope: Can your try-catch logic run on the server? Measure the impact: Compare network calls before and after implementation Sometimes the most powerful optimizations come from using the tools that were there all along. ExceptionHandlingScope isn’t just a performance optimization—it’s a reminder that the platform often provides solutions we didn’t know we were looking for. Next time you’re facing a SharePoint performance challenge, remember: the answer might not be in the latest framework or cutting-edge technique. Sometimes, it’s hiding in plain sight in the documentation you scrolled past. ExceptionHandlingScope is one of five techniques in The SharePoint CSOM Performance Playbook, alongside batching, CAML joins, change detection and fast taxonomy loading. --- ### Optimize SharePoint Webhooks: Debouncing User Actions Forfatter: Jeppe Spanggaard Dato: 2025-10-14 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-10-optimize-sharepoint-webhooks-debouncing-user-actions/ Have you ever watched a user frantically editing a SharePoint list item? Click save, make another tiny change, click save again, then realize they made a typo and… click save once more. Meanwhile, your perfectly crafted webhook is firing off like a machine gun, triggering expensive operations for every single keystroke (okay, not literally every keystroke—but you get the idea). This is exactly the problem I ran into while building a system where SharePoint list changes needed to trigger updates to site titles. Users would edit customer information, and I wanted the customer’s SharePoint site title to reflect those changes. But I definitely didn’t want to rename a site five times in thirty seconds just because someone couldn’t decide between “Acme Corp” and “ACME Corporation.” The solution? Debouncing. Just like the useDebounce hook that React developers know and love, but for SharePoint webhooks. The Business Problem Let me paint you the picture. We have a UI that updates SharePoint list items every time a user makes a change. When they modify a customer’s name, we want to update the title of that customer’s SharePoint site. Simple enough, right? Wrong. Users don’t make one clean edit and walk away. They edit the name, realize they want to change the format, edit it again, spot a typo, fix that too, and maybe adjust the capitalization for good measure. Each of these changes triggers our SharePoint webhook, which in turn tries to update the site title. Not only is this inefficient, but it’s also potentially problematic. Site title updates aren’t instant operations, and we definitely don’t want them queuing up and executing out of order. What we really want is to wait until the user is done making changes, then process all their edits as one logical operation. Enter the Debounce Pattern The concept is borrowed straight from the front-end world. Instead of reacting to every single change immediately, we wait. If another change comes in within our debounce window, we cancel the previous operation and reset the timer. Here’s the entry point to my implementation: public class WebhookProcessor() { public async Task HandleListChangeAsync(string itemId, string listId) { using (DebounceScheduler debouncer = new DebounceScheduler()) { var message = new ServiceBusMessage() { ContentType = "application/json", }; await debouncer.DebounceAsync(itemId, listId, message, TimeSpan.FromMinutes(5)); } } } Simple on the surface, but there’s quite a bit happening under the hood. The Architecture: Azure Service Bus + Table Storage I built this debounce system using two Azure services: Azure Service Bus for scheduled message delivery Azure Table Storage for tracking sequence numbers Here’s why this combination works so well: Azure Service Bus Scheduled Messages Service Bus has a fantastic feature called scheduled messages. You can schedule a message to be delivered at a specific time in the future, and critically, you can cancel that scheduled message if needed. This is perfect for debouncing. When a webhook fires, I schedule a message to be processed in 5 minutes. If another webhook fires for the same item before those 5 minutes are up, I cancel the previously scheduled message and schedule a new one. Table Storage for State Management The tricky part is remembering which messages you’ve scheduled so you can cancel them later. That’s where Table Storage comes in. The core logic looks something like this: internal async Task DebounceAsync(string entityId, string listId, ServiceBusMessage message, TimeSpan delay) { // Get any existing scheduled message for this entity var oldSeq = await store.GetSequenceNumberAsync(entityId); if (oldSeq.HasValue) { // Cancel the previous scheduled message await _sender.CancelScheduledMessageAsync(oldSeq.Value); await store.DeleteSequenceNumberAsync(entityId); } // Schedule a new message var scheduledTime = DateTimeOffset.UtcNow.Add(delay); long newSeq = await _sender.ScheduleMessageAsync(message, scheduledTime); // Store the new sequence number for future cancellation await store.SetSequenceNumberAsync(entityId, newSeq, listId); } For each item being debounced, I store: The sequence number of the scheduled Service Bus message The list ID When a new change comes in, I can look up the previous sequence number, cancel that message, and schedule a new one. The Flow in Action Let’s walk through what happens when a user edits a customer: User changes customer name → SharePoint webhook fires My webhook Azure Function receives the notification and queues it The webhook queue processor calls WebhookProcessor.HandleListChangeAsync() DebounceScheduler checks Table Storage for any existing scheduled message If one exists, it gets cancelled via the Service Bus sequence number A new message is scheduled for 5 minutes from now The new sequence number is saved to Table Storage If the user makes another change within 5 minutes, steps 4-7 repeat, effectively resetting the countdown. Once 5 minutes pass without any changes, the scheduled message is delivered to our debounce queue, and the actual processing begins. When SharePoint Gets Slow (Really Slow) Here’s where things got interesting. During testing, I discovered that SharePoint webhooks aren’t always as snappy as you’d expect. One evening, I made a change to a list item and noticed that both my webhook and a Power Automate flow (each set up for the same list) didn’t fire until 22 minutes later. The timing was unusual, especially since this happened right when the customer’s backup solution started running. I can’t say for sure what caused the delay, and I don’t want to speculate or draw any conclusions based on this single incident. However, it does highlight an important consideration: webhook delivery isn’t guaranteed to be immediate. Trade-offs and Considerations Like any architectural decision, this approach comes with trade-offs: Pros: Dramatically reduces unnecessary processing Handles the “fidgety user” problem elegantly Resilient to webhook delivery delays Scales well (Service Bus handles the heavy lifting) Works across multiple function instances Cons: Adds latency (minimum 5-minute delay) Requires additional Azure services (cost) More complex than direct processing Potential for message loss (though Service Bus is pretty reliable) Alternative Approaches I Considered: In-memory debouncing: Would work for a single instance, but doesn’t scale across multiple Azure Function instances Redis-based debouncing: Would work, but adds another dependency and requires more custom logic Database polling: Could work, but feels clunky and less efficient than Service Bus scheduled messages Simple delays: Just wait X seconds before processing, but this doesn’t handle multiple rapid changes The Service Bus + Table Storage approach won because it’s distributed by nature, leverages existing Azure services we were already using, and handles cancellation elegantly. Wrapping Up Debouncing SharePoint webhooks with Azure Service Bus and Table Storage has made our operations more efficient and resilient to user behavior and platform quirks. --- ### Get SharePoint Document Set version history Forfatter: Alexander Henkel Dato: 2025-10-14 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-10-sharepoint-document-set-version-history/ Learn how to see the version history for a Document Set in SharePoint The challenge In SharePoint when you click on the version history you will see a blank screen There is an option to select Capture Version History but it does not give you any previous versions The solution To see the version history of a Document Set in SharePoint do the following steps. Step 1 Right click on the Document Set and click version history Step 2 Open your developer tools (F12) and click Select Element tool (CTRL+SHIFT+C) Hover your mouse over No. and click it. In your developer tool you will now see a link just above the element you just selected - Click to open the link. The link will be similar to this https://yourtenant.sharepoint.com/sites/Blog2/_layouts/15/DocSetVersions.aspx?list={645e1330-5bfb-4edf-a44d-c7d1fc4a2cf3}&ID=2&col=Version&order=a&IsDlg=1 Step 3 Do the following changes to the link Replace DocSetVersions with Versions Remove &col=Version….. Your link should now be similar to this https://yourtenant.sharepoint.com/sites/Blog2/_layouts/15/Versions.aspx?list={645e1330-5bfb-4edf-a44d-c7d1fc4a2cf3}&ID=2 The version history of the Document Set is now available ‎ --- ### Collect data from multiple SharePoint sites in Power Apps Forfatter: Alexander Henkel Dato: 2025-10-03 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-10-powerapps-powerautomate-multiple-items/ Learn how to collect data from multiple SharePoint sites in Power Apps using Power Automate What will we build? In this blog post we will use Power Automate to collect data from multiple SharePoint sites. Solution Step 1 Create a new SharePoint site called Projects Create a new list with two columns Title [Text] SiteLink [Text] This article will not cover site provisioning or the content type hub Create 2 sites manually and add project name and link to the site to the list. If you use the content type hub, you can easely add the content types for each site/list. Step 2 We will create an app that collects tasks for a project Go ahead and create a new task custom content type with the following columns Title [Singe line of text] Responsible [Single person] ProjectParticipants [Multi person] TaskDescription [Multiple lines of text] Deadline [Date] Create a list called Tasks on your sites - Create a couple of tasks on each site Step 3 In Power Apps Studio add the site Projects as a datasource -> This will be be our only hardcoded datasource. Create a dropdown item, and add Projects in items Step 4 Create a new Power Automate flow and name it Projects - Collect tasks In the Power Apps (V2) action add a text input named siteURL Step 5 Create an Send an HTTP request to SharePoint action and rename it GetTasks In site address insert siteURL (from our first action). Method **GET** Headers Accept and application/json; odata=nometadata Uri _api/web/lists/getbytitle('Tasks')/items?$select=ID,Title,TaskDescription,Deadline,Responsible/EMail,Responsible/Title,ProjectParticipants/EMail,ProjectParticipants/Title&$expand=Responsible,ProjectParticipants What does Uri do? We are getting items from the list called Tasks _api/web/lists/getbytitle('Tasks')/items Then select specific columns ?$select=ID,Title,TaskDescription,Deadline,Responsible/EMail,Responsible/Title Notice how we are selecting the Email and Title from both Reponsible and ProjectParticipants Responsible/EMail,Responsible/Title,ProjectParticipants/EMail,ProjectParticipants/Title and that we are expanding those columns &$expand=Responsible,ProjectParticipants Step 6 Create an Respond to a Power App or flow action. Insert a text input and name it result The output is body from our GetTasks action. Save your flow and go back to Power Apps Studio Step 7 To collect the project tasks we will use the formulas option. We will do this in case we need to run the flow in other parts of the app. Insert the following in formulas (it is available under App) collectProjectTasks(siteURL: Text): Void = { ClearCollect( colTasks, ForAll( ParseJSON( 'Projects-Collecttasks'.Run(siteURL).result ).value, { ID: Value(ThisRecord.ID), Title: Text(ThisRecord.Title), TaskDescription: Text(ThisRecord.TaskDescription), Deadline: DateTimeValue(ThisRecord.Deadline), Responsible: Text(ThisRecord.Responsible.Title), DeadlineFormatted: Text(DateTimeValue(ThisRecord.Deadline), "dd-mm-yyyy"), ProjectParticipants: ForAll( ThisRecord.ProjectParticipants, { Name: Text(ThisRecord.Title), Email: Text(ThisRecord.EMail) } ) } ) ) }; We have now created a function called collectProjectTasks that takes one input (siteURL) Step 8 To run our new function we will add the following Power Fx to OnSelect on our Continue button. collectProjectTasks(DropdownSelectProject.Selected.SiteLink) For my Continue button I will also set a variable for the selected projects Title and SiteLink because I know I am going to need it further on This is what I have in my OnSelect collectProjectTasks(DropdownSelectProject.Selected.SiteLink); Set( varSelectedProject, LookUp( ShowColumns(Projects, ID, Title, SiteLink), ID = DropdownSelectProject.Selected.ID ) ); Navigate(scr_tasks) Now that we have all the tasks in a collection, we can show the tasks in a gallery view, like below ‎ --- ### Stop Retrying Everything: Smart Graph Batch Retry Logic Forfatter: Jeppe Spanggaard Dato: 2025-09-22 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-09-graph-batch-smart-retry/ The Day My Batch Requests Started Fighting Back Picture this: It’s 2 AM, you’re on your third cup of coffee, and you’re watching your perfectly crafted Microsoft Graph batch request fail spectacularly. Again. You’ve got 25 files to download from SharePoint. Your batch processes 24 of them perfectly, then one lonely file decides to throw a throttling tantrum. What does your retry logic do? It throws away all 24 successful downloads and starts over. From scratch. Like a digital Groundhog Day, but less amusing and more soul-crushing. Sound familiar? Welcome to the “retry everything” club – where perfectly good API calls go to die unnecessarily. 😅 The Grocery Cart Problem (Or: Why We’re Doing This Wrong) Let me paint you a picture. You’re at the grocery store with a cart full of 20 items. You get to checkout, and the cashier says, “Sorry, we’re out of milk.” What would you do? Option A: Put back everything, go home, and come back later to shop for all 20 items again Option B: Buy the 19 items you can get, then come back just for the milk If you picked Option A, congratulations – you think like most API retry logic! If you picked Option B, you’re ready to learn about smart retries. The “retry everything” approach is like Option A, and here’s why it’s bonkers: 🔄 Wasted effort: You’re re-requesting stuff that already worked perfectly 🐌 Slower performance: Users wait longer while you redo successful work 📈 Throttling amplification: You’re actually making the problem worse by hitting successful endpoints again 🔍 Poor debugging: Can’t easily identify which specific requests are the real troublemakers I learned this the hard way when I watched a simple file sync turn into an API call avalanche. 20 requests became 40, then 80, then… well, let’s just say Microsoft’s throttling system got very acquainted with my application. The “Aha!” Moment (It’s Simpler Than You Think) The solution hit me during one of those 2 AM debugging sessions: What if we only retry the stuff that actually failed? Revolutionary, right? 😏 Here’s the beautiful thing – this isn’t some PhD-level computer science. It’s just common sense applied to code. Keep the winners, retry the losers. Simple. But (there’s always a “but”), there’s one sneaky technical challenge that makes this trickier than it sounds. Microsoft’s Graph SDK has a helpful method called NewBatchWithFailedRequests(), but it has a quirk: it generates brand new request IDs. This breaks your ability to map responses back to your original data. Think of it like this: You order pizza for table 5, but when they bring the replacement slice, they call it table 23. Good luck figuring out who ordered what! If you’re new to Graph batching or request mapping, I’d recommend checking out my post on Graph Batching for File Content: Mapping Requests to Responses first. It’s like the prequel to this story – explains how to keep track of what’s what when dealing with batch responses. Quick Win Summary (For the Impatient Developers) The Problem: Your retry logic is like that friend who starts the entire conversation over when they missed one word. Inefficient and annoying. The Solution: A drop-in extension method that only retries the actual failures while keeping successful responses safe and sound. The Payoff: ⚡ Faster operations (no more re-downloading working files) 📉 Fewer API calls (your rate limits will thank you) 🎯 Less throttling (stop beating dead endpoints) 😌 Happier users (and happier you at 2 AM) The Catch: You need to understand request-to-response mapping. Don’t worry, it’s not rocket science, and I’ve got a whole post about it. Time Investment: About 5 minutes to implement, countless hours of frustration saved. Ready for the nitty-gritty? Let’s dive in! 👇 The Hero of Our Story: The Smart Retry Extension Okay, here’s where we get our hands dirty. The main challenge isn’t just filtering out successful requests – it’s that pesky NewBatchWithFailedRequests method that scrambles your request IDs like eggs at Sunday brunch. Here’s the extension method that saves the day: internal static class GraphServiceClientExtensions { internal static async Task<(IReadOnlyDictionary Statuses, Dictionary BatchResponse)> PostBatchWithFailedDependencyRetriesAsync(this GraphServiceClient graphClient, BatchRequestContentCollection originalBatch) { const int maxRetries = 5; TimeSpan delay = TimeSpan.FromSeconds(1); Dictionary allResponses = new Dictionary(); Dictionary allStatuses = new Dictionary(); BatchRequestContentCollection batchToSend = originalBatch; for (int attempt = 1; attempt <= maxRetries; attempt++) { BatchResponseContentCollection batchResponse = await graphClient.Batch.PostAsync(batchToSend); Dictionary responses = await batchResponse.GetResponsesStatusCodesAsync(); // Filter out failures (excluding redirects which are normal for file content) Dictionary failedRequests = responses .Where(kvp => !BatchResponseContent.IsSuccessStatusCode(kvp.Value) && kvp.Value != HttpStatusCode.Found) .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); // Collect all responses from this attempt foreach (var kvp in responses) { var response = await batchResponse.GetResponseByIdAsync(kvp.Key); allResponses[kvp.Key] = response; allStatuses[kvp.Key] = kvp.Value; } if (failedRequests.Count == 0 || attempt == maxRetries) { break; } await Task.Delay(delay); delay = TimeSpan.FromSeconds(delay.TotalSeconds * 2); // The key problem: NewBatchWithFailedRequests creates new request IDs! batchToSend = batchToSend.NewBatchWithFailedRequests(responses); // This is why we need this method - restore the original request IDs await RestoreOriginalRequestIdsAsync(batchToSend, originalBatch); } return (allStatuses, allResponses); } private static async Task RestoreOriginalRequestIdsAsync( BatchRequestContentCollection newBatch, BatchRequestContentCollection originalBatch) { var stepsSnapshot = newBatch.BatchRequestSteps.ToArray(); foreach (var kvp in stepsSnapshot) { var oldStepId = kvp.Key; var step = kvp.Value; var requestPath = step.Request.RequestUri!.AbsolutePath; // Find the original request ID by matching the request path var matchingOriginal = originalBatch.BatchRequestSteps .First(x => x.Value.Request.RequestUri!.AbsolutePath == requestPath); var originalStepId = matchingOriginal.Key; if (oldStepId != originalStepId) { newBatch.RemoveBatchRequestStepWithId(oldStepId); var newStep = new BatchRequestStep( requestId: originalStepId, httpRequestMessage: step.Request, dependsOn: step.DependsOn); newBatch.AddBatchRequestStep(newStep); } } } } What’s happening here? Think of it as a diplomatic negotiator for your API calls: The ID Shuffle Problem: NewBatchWithFailedRequests gives failed requests shiny new IDs, like witness protection for HTTP requests The Detective Work: RestoreOriginalRequestIdsAsync plays detective, matching requests by their paths to find their original identities The Happy Reunion: Failed requests get their original IDs back, so your mapping dictionary doesn’t break down in tears It’s like having a really good wedding planner who makes sure everyone sits at the right table, even after the venue changes. Showtime: Watching Smart Retries in Action Now let’s see our smart retry logic work its magic in a real-world scenario. Imagine you’re building a document sync tool and need to download 25 files from SharePoint. Some will work perfectly, others might throw tantrums due to throttling or network hiccups. Here’s how the new approach handles it like a champ: // Let's say you need to download content from 25 SharePoint files var batch = new BatchRequestContentCollection(graphClient); var fileMapping = new Dictionary(); // Build the batch for file content downloads var filesToDownload = await GetFilesToProcess(); // Your method to get file list foreach (var file in filesToDownload) { var requestInfo = graphClient.Sites[siteId] .Drives[driveId] .Items[file.DriveItemId] .Content .ToGetRequestInformation(); var requestId = await batch.AddBatchRequestStepAsync(requestInfo); // Map the request ID to your file info (same pattern as previous post) fileMapping[requestId] = new FileContentRequest { DriveItemId = file.DriveItemId, FileName = file.Name, ExpectedSize = file.Size, DownloadStartTime = DateTime.Now }; } // 🎯 Here's where the magic happens - just one line change! var (statuses, responses) = await graphClient.PostBatchWithFailedDependencyRetriesAsync(batch); // Process results - this is where the retry really shines var successfulDownloads = new List(); var failedDownloads = new List(); foreach (var kvp in fileMapping) { var requestId = kvp.Key; var fileRequest = kvp.Value; if (responses.TryGetValue(requestId, out var response)) { var statusCode = statuses[requestId]; if (BatchResponseContent.IsSuccessStatusCode(statusCode)) { // Success! Handle the file content var contentBytes = await response.Content.ReadAsByteArrayAsync(); // Save to your desired location var localPath = Path.Combine(downloadFolder, fileRequest.FileName); await File.WriteAllBytesAsync(localPath, contentBytes); successfulDownloads.Add(new FileDownloadResult { FileName = fileRequest.FileName, LocalPath = localPath, ActualSize = contentBytes.Length, ExpectedSize = fileRequest.ExpectedSize, DownloadTime = DateTime.Now - fileRequest.DownloadStartTime }); } else { // Even after smart retries, this file failed failedDownloads.Add($"{fileRequest.FileName} ({statusCode})"); // Log the specific failure for debugging Console.WriteLine($"Failed to download {fileRequest.FileName}: {statusCode}"); } // Always clean up the response response.Dispose(); } } Console.WriteLine($"Successfully downloaded: {successfulDownloads.Count} files"); if (failedDownloads.Any()) { Console.WriteLine($"Failed downloads: {string.Join(", ", failedDownloads)}"); } The beautiful part? Look at that line with the magic emoji 🎯. That’s literally the only change you need to make to your existing batch processing code. Everything else stays exactly the same. Here’s what’s happening behind the scenes: First batch attempt: Say 20 files succeed, 5 fail due to throttling Smart filtering: Keep those 20 successful responses safe Targeted retry: Build a new batch with just the 5 failures ID preservation: Make sure those 5 retries still map to your original file info Rinse and repeat: Maybe 4 of the 5 succeed on retry, leaving just 1 persistent troublemaker The result? Instead of making 250 API calls (25 files × 5 retry attempts for the unlucky ones), you might only make 35 total calls. Your throttling problems become manageable, and files download way faster. // Supporting classes for the example above public class FileContentRequest { public string DriveItemId { get; set; } = string.Empty; public string FileName { get; set; } = string.Empty; public long ExpectedSize { get; set; } public DateTime DownloadStartTime { get; set; } } public class FileDownloadResult { public string FileName { get; set; } = string.Empty; public string LocalPath { get; set; } = string.Empty; public long ActualSize { get; set; } public long ExpectedSize { get; set; } public TimeSpan DownloadTime { get; set; } } The Method to the Madness (What’s Really Happening) I know that extension method looks intimidating – like trying to read assembly instructions in a foreign language. But once you break it down, it’s actually pretty logical. Let me walk you through the step-by-step dance: Step 1: The First Attempt “Let’s try everything once and see what happens” Sends your original batch of 25 files and carefully captures every single response and status code. No throwing anything away yet. Step 2: The Great Sorting “Okay, who succeeded and who’s being difficult?” Separates the winners from the losers, but (and this is important) ignores redirect responses. Why? Because when you’re downloading large files, redirects are totally normal – SharePoint often redirects you to the actual storage location. Step 3: The Preservation Society “Keep the good stuff safe while we deal with the troublemakers” All successful responses get stored in a safe place while we build a new, smaller batch containing only the failed requests. It’s like having a really good filing system for your API responses. Step 4: The Identity Crisis Resolution “Wait, who are you again? Let me check your original ID…” This is the tricky bit! The NewBatchWithFailedRequests method gives everyone new IDs, like a witness protection program for HTTP requests. Our RestoreOriginalRequestIdsAsync method plays detective, matching requests by their URL paths to restore their original identities. Step 5: The Polite Wait “Let’s not be pushy – maybe try again in a second?” Implements exponential backoff – starts with a 1-second wait, then 2 seconds, then 4 seconds, etc. This prevents your app from being that annoying person who keeps knocking on the door every second. Step 6: The Safety Net “Okay, we tried 5 times. Some files just aren’t meant to be downloaded today.” Gives up after 5 attempts to prevent infinite retry loops. Because sometimes you need to know when to walk away from the poker table. Why This Actually Works (The Science Behind the Magic) Here’s what makes this approach so much better than the “retry everything” strategy: 🎯 Surgical Precision Only retry what actually failed – it’s like having a really good therapist who focuses on the actual problems instead of rehashing everything from childhood. No wasted API calls on requests that already succeeded. If 24 out of 25 files downloaded perfectly, why punish them with another round trip? ⚡ Speed Demon Successful requests don’t get repeated, so everything finishes faster – sometimes dramatically faster. Users see their successful downloads immediately while you quietly retry the problematic ones in the background. 🤝 Throttling-Friendly Fewer total requests means you’re less likely to hit Microsoft’s rate limits, and when you do, recovery is faster. Instead of amplifying throttling issues, you’re actually helping to resolve them by reducing load on the endpoints that are already struggling. 🔄 Drop-in Simplicity Change literally one line of code and you’re done. No architectural rewrites, no complex state management – just swap out the method call. Your existing error handling, logging, and business logic all stay exactly the same. 🔍 Debug Paradise Easy to see exactly which requests are consistently failing, making troubleshooting a breeze instead of a nightmare. When file “ImportantDocument.pdf” fails on every retry attempt, you know there’s something specific about that file, not your entire batch logic. The “Before and After” Moment Let me paint you a picture of how this changes your life: Before Smart Retries: 25 file batch fails on 3 files due to throttling Retry all 25 files → now 5 files fail due to increased throttling Retry all 25 files again → now 8 files fail You’re now in the throttling spiral of doom Users are staring at loading spinners You’re questioning your career choices After Smart Retries: 25 file batch fails on 3 files due to throttling Keep the 22 successful files, retry only the 3 failures Maybe 2 of the 3 succeed on retry, leaving 1 stubborn file Final retry gets the last file, or you log it as a persistent issue Users get 24/25 files quickly, you sleep better at night It’s the difference between being stuck in traffic because one lane is blocked (and everyone keeps switching to that lane), versus just using the open lanes and going around the problem. The Bottom Line (And Why Your Future Self Will Thank You) I’ll be real with you – when I first started working with Microsoft Graph batching, I thought the built-in retry policies were enough. “How hard could it be?” I thought. “APIs fail sometimes, just retry them!” Then I built my first real-world document sync application. Suddenly, I was dealing with users uploading hundreds of files, enterprise throttling limits, and the occasional network hiccup that would bring the whole operation to a screeching halt. That’s when I learned the hard way that “retry everything” is like using a sledgehammer to hang a picture frame. Sure, it might work, but you’re probably going to break some stuff in the process. This selective retry approach has been a game-changer. Not just for performance (though users definitely notice when their bulk operations actually complete), but for debugging too. When you can see that ImportantReport_v23_FINAL_REALLY_FINAL.docx is the file that keeps failing, you can actually do something about it. The best part? Once you have this extension method in your toolkit, it becomes muscle memory. You’re not adding complexity to your day-to-day development – you’re just swapping out one method call for a smarter one. It’s like upgrading from a flip phone to a smartphone – you wonder how you ever lived without it. Pro tip: After implementing this, keep an eye on your application logs. You’ll start to notice patterns in failures that you never saw before. Maybe certain file types are more prone to issues, or maybe there’s a specific time of day when throttling gets worse. This kind of insight is pure gold for optimization. The moral of the story? Sometimes the biggest performance improvements come not from doing things faster, but from doing fewer unnecessary things. And sometimes, the best debugging tool is just… not breaking the working stuff while you fix the broken stuff. Your 2 AM debugging sessions will never be the same. 😌 Want to Learn More? (The Reading List) Microsoft Graph JSON batching - The official documentation (surprisingly readable!) Graph Batching for File Content: Mapping Requests to Responses - My previous post that sets up the foundation for this one Microsoft Graph throttling guidance - Understanding what makes Microsoft’s APIs cranky Graph Explorer - Test your batch requests interactively (great for experimenting) Exponential Backoff Pattern - The polite way to retry things Now go forth and batch smarter, not harder! 🚀 --- ### Work with multi-choice, multi-lookup & multi-choice-people fields from SharePoint in Power BI Forfatter: Alexander Henkel Dato: 2025-09-21 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-09-powerbi-sharepoint-multichoice/ Learn how to visualize and filter from various multi choice options in Power BI from SharePoint The challenge When working with SharePoint data in Power BI, it can be a challenge to expand and filter lookup, people and choice fields with multiple values Here you will learn how to both visualize and filter multi choice values Initial setup Create a new list called Project with a choice column called ProjectType with the following options Power Automate SharePoint Copilot Don’t forget to the set value Allow multiple selections to Yes Get data In Power Query create a new blank query and click advanced editor Insert the following code (with needed adjustments, explained below the code snippet) let BaseURL = "https://YOURTENANTNAME.sharepoint.com/sites/YOURSITENAME/", ListName = "YOURLISTNAME", Select = "Title,Author,ProjectType,ID", ExpandColumns = "Author/Title", Expand = "Author", CountItems = Json.Document(Web.Contents(BaseURL, [RelativePath = "_api/web/lists/GetByTitle('" & ListName & "')/items?$select=ID&$orderby=ID%20desc&$top=1", Headers = [Accept = "application/json"]]))[value]{0}[ID], StartID = List.Numbers(0, Number.RoundUp(CountItems / 5000), 5000), #"Converted to Table" = Table.FromList(StartID, Splitter.SplitByNothing(), null, null, ExtraValues.Error), EndID = Table.AddColumn(#"Converted to Table", "Custom", each [Column1] + 4999, type number), #"Renamed Columns" = Table.RenameColumns(EndID,{{"Column1", "StartID"}, {"Custom", "EndID"}}), #"Changed Type" = Table.TransformColumnTypes(#"Renamed Columns",{{"StartID", type text}, {"EndID", type text}}), selectedfields = "&$top=5000&$select="& Select & "," & ExpandColumns & "&$expand=" & Expand, ListItems = Table.AddColumn(#"Changed Type", "ListItems", each Json.Document(Web.Contents(BaseURL, [RelativePath = "_api/web/lists/GetByTitle('" & ListName & "')/items?$filter=(ID ge " & [StartID] & ") and (ID le " & [EndID] & ")&$top=5000&$select="& Select & "," & ExpandColumns & "&$expand=" & Expand, Headers = [Accept = "application/json"]]))[value]), #"Removed Other Columns" = Table.SelectColumns(ListItems,{"ListItems"}), #"Expanded ListItems" = Table.ExpandListColumn(#"Removed Other Columns", "ListItems") in #"Expanded ListItems" Change the following variables BaseUrl Change to your own site (don’t forget the last /) ListName: Change to your list name (the actual name, the one you see on your site) ExpandColumns: If you want to expand any columns like a people field, and get the title (name) or the EMail, you insert the expand columns here, like below: ExpandColumns = "Author/Title,Editor/Title,PersonColumn/EMail,PersonColumn/Title,LookupColumn/Title", Expand: Everytime you introduce a new column to expand, it is important you write the name here, like this: Expand = "Author,Editor,PersonColumn,LookupColumn", Select: We are only selecting the actual columns that we need. You can replace it with an asterix (*) that will return all columns from SharePoint Select = "Title,Author,ProjectType,ID" Step 2 You can now select the arrow icon and click expand Unclick use original column name as prefix and click OK Notice that everytime you introduce a new column, you need to go back to this step, and select the new column Note the that code above will only work if you have at least 1 expand column introduced. Setup staging and transform Before you continue with your steps, setup staging, transform, dim, & fact tables. Do the initial setup like below Multichoice We are doing this change in TransformProject Your multichoice will be shown currently as a list: We want to extract the values, so that the user can see the values in the report. Select the two arrows icon in the top right corner and select Extract Values Choose Custom and extract with a comma and a space The column is now available visually for the user in a report. Filtering a multichoice To be able to filter the ProjectType we will create a new dimension. Create a reference from your staging table and move it to the MultiChoice group Either Remove or Choose columns so that we only have ID and ProjectType in our table. This time we expand ProjectType to New rows Finally we reference our multichoice table to our dimensions You can now close Power Query for the final step Model In the model reference ID with ID in both tables, and set the direction to Both Report We can now create a report From the table called Project insert Title and Project Type into a table visual From the table called ProjectType insert the ProjectType column into a slicer visual We can now filter a multi choice column Here we see the table unfiltered Here we see the table filtered Multi people column Step 1 Let’s go back to staging and add ProjectTeam to our select and expand Select = "Title,ProjectType,ID,Author,ProjectTeam", ExpandColumns = "Author/Title,ProjectTeam/Title", Expand = "Author,ProjectTeam", Don’t forget to add the new column from your last step Step 2 Create a new reference to your staging table. Remove the other columns so that you only have ID and ProjectTeam Step 3 Expand ProjectTeam to New rows and next expand to title Mark your ID column and in the menu under Transformation select Group By Choose the settings as below Create a new custom column and write: [Group][Title] Extract the values with a custom “, " In your transform table we can now merge the project team and expand it Your table should now look like this Step 4 Now we need to create a dimension table so that we can filter. Create a new reference to your staging table and keep ID and ProjectTeam Expand ProjectTeam to new rows and expand Title Your table should now look like this Step 5 In the model view we can now bind the Project table with our dimension table In the report view add a slicer with the names from our dimension table, and in the table view add the column from our project view Our report can now filter on a multi choice people column Multi lookup column The lookup column follows the same principle as the people field. For this list we will also add ISO to the list, so that we can filter on both the country name and country ISO Step 1 Add countries to the staging table and expand both Title and ISO Select = "Title,ProjectType,ID,Author,ProjectTeam,Countries", ExpandColumns = "Author/Title,ProjectTeam/Title,Countries/Title,Countries/ISO", Expand = "Author,ProjectTeam,Countries", Step 2 Reference a new table and group them by ID and create new custom columns for both Title and ISO [Group][Title] [Group[ISO]] Here are the full steps let Source = SharePointProject, #"Removed Other Columns" = Table.SelectColumns(Source,{"Countries", "ID"}), #"Expanded Countries" = Table.ExpandListColumn(#"Removed Other Columns", "Countries"), #"Expanded Countries1" = Table.ExpandRecordColumn(#"Expanded Countries", "Countries", {"Title", "ISO"}, {"Title", "ISO"}), #"Grouped Rows" = Table.Group(#"Expanded Countries1", {"ID"}, {{"Group", each _, type table [Title=text, ISO=text, ID=number]}}), #"Added Custom" = Table.AddColumn(#"Grouped Rows", "Country", each [Group][Title]), #"Added Custom1" = Table.AddColumn(#"Added Custom", "ISO", each [Group][ISO]), #"Extracted Values" = Table.TransformColumns(#"Added Custom1", {"ISO", each Text.Combine(List.Transform(_, Text.From), ", "), type text}), #"Extracted Values1" = Table.TransformColumns(#"Extracted Values", {"Country", each Text.Combine(List.Transform(_, Text.From), ", "), type text}), #"Removed Columns" = Table.RemoveColumns(#"Extracted Values1",{"Group"}), #"Changed Type" = Table.TransformColumnTypes(#"Removed Columns",{{"ID", Int64.Type}, {"Country", type text}, {"ISO", type text}}) in #"Changed Type" Step 3 Merge the table into the transform table Step 4 Reference the staging table to create a new dimension table. As we did earlier, expand to new rows Here are the full steps let Source = SharePointProject, #"Removed Other Columns" = Table.SelectColumns(Source,{"Countries", "ID"}), #"Expanded Countries" = Table.ExpandListColumn(#"Removed Other Columns", "Countries"), #"Expanded Countries1" = Table.ExpandRecordColumn(#"Expanded Countries", "Countries", {"Title", "ISO"}, {"Title", "ISO"}), #"Changed Type" = Table.TransformColumnTypes(#"Expanded Countries1",{{"Title", type text}, {"ISO", type text}, {"ID", Int64.Type}}) in #"Changed Type" Step 5 Relate the dimension table to the fact table We can now filter on both country and ISO You can download my example Power BI report here ‎ --- ### Graph Batching for File Content: Mapping Requests to Responses Forfatter: Jeppe Spanggaard Dato: 2025-09-10 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-09-graph-batching-file-content-mapping/ The Problem That’ll Drive You Crazy Picture this: you need to download 50 files from SharePoint using Microsoft Graph. Being a good developer, you decide to use batching instead of making 50 individual API calls (because nobody wants to wait that long, and Microsoft’s throttling limits aren’t going anywhere). You set up your batch request, send it off, and get your responses back. Great! Except… now you’re staring at a bunch of file content with absolutely no way to tell which file is which. 😅 Unlike other Graph operations that return nice JSON objects with IDs and metadata, file content responses are just raw bytes. No file name, no path, no ID - nothing to help you figure out which response belongs to which original request. I learned this the hard way when I first tried Graph batching for file downloads. Spent way too much time trying to correlate responses by file size or content patterns before realizing there was a much cleaner solution. The Mapping Solution (It’s Simpler Than You Think) The trick is surprisingly straightforward: use the batch request ID as your bridge between the original file info and the response content. Every batch request gets a unique ID, and that same ID comes back with the response. Here’s the game plan: Create a dictionary mapping request IDs to your original file information Build your batch requests and store the mappings Process responses using the request ID to look up the original file info Let me show you exactly how this works. Setting Up Your File Information First, let’s create a simple model to hold our file details: public class FileInfoDTO { public string? Path { get; set; } public string? Name { get; set; } public string? RelativePath { get; set; } public string? UniqueFileName { get; set; } } Nothing fancy here - just the basics we need to identify and process each file. The Complete Batch Download Method Here’s the full implementation. Don’t worry, I’ll break down the important parts afterward: private async Task> DownloadFilesBatchAsync( FileInfoDTO[] fileInfos, string siteId, string driveId) { using BatchRequestContent batchRequestContent = new BatchRequestContent(); Dictionary requestMapping = new Dictionary(); Dictionary results = new Dictionary(); // Build batch requests with mapping foreach (FileInfoDTO fileInfo in fileInfos) { if (string.IsNullOrEmpty(fileInfo.RelativePath) || string.IsNullOrEmpty(fileInfo.UniqueFileName)) continue; string requestId = batchRequestContent.AddBatchRequestStep( GraphClient.Sites[siteId] .Drives[driveId] .Root .ItemWithPath(fileInfo.RelativePath) .Content .Request()); // This is the magic - storing the mapping! requestMapping[requestId] = fileInfo; } // Execute batch request BatchResponseContent batchResponse = await GraphClient.Batch.Request().PostAsync(batchRequestContent); Dictionary responses = await batchResponse.GetResponsesAsync(); // Process responses using our mapping foreach ((string requestId, HttpResponseMessage response) in responses) { FileInfoDTO originalFile = requestMapping[requestId]; // Look up the original file info try { switch (response.StatusCode) { case HttpStatusCode.OK: byte[] content = await response.Content.ReadAsByteArrayAsync(); results[originalFile.UniqueFileName!] = content; break; case HttpStatusCode.Redirect: // Handle redirect for large files (more on this below) byte[] redirectContent = await DownloadFromRedirectAsync(response.Headers.Location); results[originalFile.UniqueFileName!] = redirectContent; break; case HttpStatusCode.TooManyRequests: // Handle throttling throw new Exception($"Throttled request for {originalFile.Name}"); default: throw new Exception($"Failed to download {originalFile.Name}: {response.ReasonPhrase}"); } } finally { // Always dispose - learned this one the hard way after some memory leak hunting response.Content.Dispose(); } } return results; } The Key Parts Explained The Mapping Dictionary Dictionary requestMapping = new Dictionary(); This is your lifeline. For every request you add to the batch, you store the request ID and link it to your original file information. When responses come back, you can instantly look up which file each response belongs to. Building Requests with Mapping string requestId = batchRequestContent.AddBatchRequestStep(...); requestMapping[requestId] = fileInfo; The AddBatchRequestStep method returns a unique request ID. Store this immediately - you’ll need it to match responses later. Handling the Redirect Curveball Here’s something that caught me off guard initially: large files don’t return content directly. Instead, Graph gives you a redirect to an Azure Blob Storage URL where the actual file lives. Microsoft doesn’t specify the exact file size threshold, but in practice, I’ve observed this happening with files larger than a few MB. private async Task DownloadFromRedirectAsync(Uri? redirectUri) { if (redirectUri == null) throw new ArgumentException("Redirect URI is null"); using HttpClient httpClient = new HttpClient(); return await httpClient.GetByteArrayAsync(redirectUri); } This happens because Microsoft doesn’t want to push huge files through the Graph API unnecessarily. The redirect URL is temporary and works great - just make sure you handle it properly. Note: The exact file size that triggers a redirect isn’t officially documented by Microsoft, so always handle both direct content (200 OK) and redirect (302) responses in your code. Error Handling That Actually Helps When things go wrong (and they will), you want meaningful error messages. Here’s how to handle the common scenarios: private async Task ProcessBatchResponseAsync( string requestId, HttpResponseMessage response, FileInfoDTO originalFile, Dictionary results) { try { switch (response.StatusCode) { case HttpStatusCode.OK: byte[] content = await response.Content.ReadAsByteArrayAsync(); results[originalFile.UniqueFileName!] = content; break; case HttpStatusCode.Redirect: case HttpStatusCode.Found: byte[] redirectContent = await DownloadFromRedirectAsync(response.Headers.Location); results[originalFile.UniqueFileName!] = redirectContent; break; case HttpStatusCode.NotFound: Console.WriteLine($"File not found: {originalFile.Name} at {originalFile.RelativePath}"); // Maybe the file was moved or deleted break; case HttpStatusCode.TooManyRequests: Console.WriteLine($"Throttled request for: {originalFile.Name}"); // This is where retry logic would go (coming in the next post!) break; case HttpStatusCode.Forbidden: Console.WriteLine($"Access denied for: {originalFile.Name}"); // Check your permissions break; default: Console.WriteLine($"Unexpected error downloading {originalFile.Name}: {response.StatusCode} - {response.ReasonPhrase}"); break; } } finally { response.Content?.Dispose(); // Don't leak memory! } } Important Things to Remember Batch Size Limits Graph batching has a hard limit of 20 requests per batch. If you have more files, you’ll need to chunk them: const int BATCH_SIZE = 20; for (int i = 0; i < fileInfos.Length; i += BATCH_SIZE) { var batch = fileInfos.Skip(i).Take(BATCH_SIZE).ToArray(); var batchResults = await DownloadFilesBatchAsync(batch, siteId, driveId); // Merge results... } Memory Management Always dispose of HttpResponseMessage.Content. File downloads can be large, and forgetting to dispose will cause memory leaks that are painful to debug. Request ID Uniqueness Request IDs are unique within a single batch, but not across different batches. Don’t try to reuse mappings between different batch operations. Why This Pattern Works So Well This approach has several advantages that make it my go-to solution: Dead Simple: No complex logic, just a straightforward mapping pattern Reliable: Works consistently regardless of file sizes or response order Memory Efficient: Proper cleanup prevents memory leaks Debuggable: Easy to trace issues when something goes wrong Extensible: Perfect foundation for adding retry logic later The beauty is in its simplicity. You’re not trying to guess which response belongs to which request - you know exactly because you mapped it from the start. What’s Next? This mapping pattern solves the core problem of correlating Graph batch responses with your original requests. But what happens when some requests fail due to throttling or temporary errors? In my next post, I’ll show you how to build retry logic on top of this foundation that automatically handles failed requests without losing track of which files still need to be downloaded. Have you run into this mapping challenge before? Let me know in the comments how you solved it - I’m always curious about different approaches! 🚀 --- ### Batch Your CSOM Operations Instead of Looping One by One Forfatter: Jeppe Spanggaard Dato: 2025-08-31 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-08-csom-performance-optimization-chunking/ The Problem: One-by-One Operations Kill Performance Following up on my previous post about DevProxy and throttling testing, there’s another critical performance issue I see regularly in SharePoint CSOM code: executing operations one by one instead of batching them. Consider this common pattern that I see everywhere: // ❌ This approach is slow and inefficient foreach (var id in itemIds) { var item = list.GetItemById(id); context.Load(item); await context.ExecuteQueryAsync(); // Executing for EACH item! // Process the item ProcessItem(item); } This code makes a server round-trip for every single item. If you’re processing 50 items, that’s 50 separate calls to SharePoint. Each call has network latency and server processing time. The Solution: Batch Operations SharePoint CSOM reliably handles batches of around 100 operations before calling ExecuteQueryAsync(). This means you can dramatically reduce the number of server round-trips. Microsoft’s official documentation also emphasizes this pattern in their performance guidelines, showing how grouping data retrieval operations significantly improves performance. Here’s the pattern I use in most of my SharePoint projects: The Helper Methods public static async Task> GetItemsByIds(this List list, IEnumerable ids) where T : new() { if (ids == null || !ids.Any()) { return new List(); } var listItems = await CSOMHelpers.ProcessInChunks(ids.ToList(), 100, async chunk => { var items = chunk.Select(id => { var item = list.GetItemById(id); list.Context.Load(item); return item; }).ToList(); await ExecuteQuery(list.Context, () => { /* Load calls already done above */ }); return items; }); return listItems.ToList(); } internal static async Task> ProcessInChunks(List source, int chunkSize, Func, Task>> action) { var result = new List(); foreach (var chunk in source.Chunk(chunkSize)) { result.AddRange(await action(chunk.ToList())); } return result; } Performance Impact: The Numbers Let me show you the difference with a real-world example. Loading 50 SharePoint list items: Before (One-by-One) 50 server calls Network latency: 50ms × 50 = 2.5 seconds Actual total time is usually higher due to server-side processing, hence: ~3-5 seconds After (Batched) 1 server call (all 50 items in one batch) Network latency: 50ms × 1 = 50ms Total time: ~200-500ms ⚠️ Performance Disclaimer The numbers shown above are from a specific example scenario and are meant to illustrate the concept of batching benefits. Your actual performance gains will vary significantly based on: Network latency and bandwidth SharePoint server load and location Item complexity and field count Query complexity and filtering Authentication overhead Time of day and concurrent users While batching almost always improves performance, the exact improvement factor can range from marginal gains to dramatic improvements. The key takeaway is the pattern and approach, not the specific numbers. Applying the Pattern to CRUD Operations This pattern works for all CSOM operations, not just reading, but for all the CRUD operations. Why 100 Items? SharePoint has practical limits on how many operations you can batch: CSOM limit: Around 100 operations per batch REST API limit: Different limits depending on operation type Network payload: Larger batches mean bigger HTTP requests I’ve found 100 items to be the sweet spot that: Maximizes performance gains Stays well within SharePoint limits Keeps HTTP payloads manageable Works reliably across different SharePoint environments Push past those limits and SharePoint tells you, in one of two ways. Too many operations in a single ExecuteQuery gives you The request uses too many resources. Too much payload in one request gives you The server does not allow messages larger than 2097152 bytes - that’s 2 MB, and in SharePoint Online it’s a server-side cap you can’t raise from client code. No configuration setting, no header, no retry gets you past either one. Splitting the batch is the fix, which is what the helper above is for. Combining with Throttling Protection When you combine this batching approach with the throttling protection patterns from my DevProxy post, you get robust, high-performance SharePoint applications. Best Practices Batch whenever you can - even for small numbers of items, batching usually helps. Use 100 as your chunk size for most scenarios Combine with retry logic for production resilience Test with DevProxy to ensure your batching works under throttling conditions Monitor performance before and after implementing batching The Bottom Line Batching CSOM operations is one of the easiest wins for SharePoint performance optimization. The code pattern is straightforward to implement and reuse, but the performance impact is dramatic. Stop executing SharePoint operations one by one. Your users (and SharePoint servers) will thank you. Batching is the first of five techniques I use to keep CSOM cheap. The rest, and the order I reach for them in, are in The SharePoint CSOM Performance Playbook. Resources SharePoint CSOM Best Practices DevProxy for Testing Throttling SharePoint Performance Guidelines --- ### DevProxy: How to Test API Rate Limiting and Throttling in C# Development Forfatter: Jeppe Spanggaard Dato: 2025-08-10 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-08-devproxy-throttling-testing/ The Problem: When Parallel Programming Backfires If you’ve ever optimized your C# API calls with parallel programming, you might know this story. Your client wants faster performance. You run multiple API requests in parallel. Locally, everything flies — especially late at night when traffic is low. But the next morning, on the final pre-deployment test, disaster strikes. Random errors. Requests delayed for 45 seconds. Microsoft Graph or SharePoint has slammed you with throttling and rate limits. Your blazing-fast local solution crumbles under production-like conditions. How CRUD Operations Can Secretly Burn Through Your Limits — and How to Catch Them with DevProxy Here’s the thing: SharePoint Online charges “Resource Units” (RUs) for every request you make. Think of RUs as an invisible currency — every API call you send deducts from your allowance. Run out too fast, and throttling kicks in. And those “simple” operations? They’re not as cheap as you think. From Microsoft’s RU table: Operation Type RU Cost What That Means Single item query 1 RU Reading one specific list item Multi-item query 2 RUs Listing children, filtering, sorting Create / Update / Delete / Upload 2 RUs Per individual operation (bulk operations may be more efficient) Permission expansion ($expand=permissions) 5 RUs Heavy permission lookups Source: Microsoft’s official SharePoint throttling documentation Permission expansions are just one type of “expensive” call. Another common scenario that can impact RU consumption is making multiple separate queries instead of using CAML joins — something I explored in my CAML join post. While joining multiple lists in a single query is actually more efficient than separate calls, poorly structured queries or retrieving unnecessarily large datasets can still consume RUs quickly if not optimized properly. Why This Sneaks Up on You When coding locally, it’s easy to run a handful of queries without noticing. But in production, with real concurrency and volume, these calls pile up in seconds. Imagine: 10 parallel updates (2 RUs each) = 20 RUs in one burst Add a few joins/expansions or large list queries, and you’re suddenly burning through limits 5x faster. How DevProxy Can Show You the Pain Before Production Here’s where DevProxy shines. If you configure it to simulate throttling based on these RU-heavy calls, you’ll see the impact locally: Enable throttling plugins in your devproxy.json (as shown earlier). Point urlsToWatch at your SharePoint endpoints. Run your CRUD-heavy code. DevProxy will start throwing 429s (Too Many Requests) once your “fake” RU budget is exhausted — just like SharePoint would in production. The beautiful part? You can crank the limits down during testing to make expensive patterns obvious. Even a single large query or unbatched Update will light up your logs. Example DevProxy log when hitting a throttling simulation: [Warning] Throttling triggered: 2 parallel Create calls exceeded RU limit (RateLimitingPlugin) Retry after: 30 seconds Pro tip: Use DevProxy as a budget meter for your API calls. Treat every 2-RU and 5-RU operation as “spending big” — and redesign those spots before they become a production outage. What is DevProxy? DevProxy is an open-source HTTP/HTTPS proxy server that can simulate real-world network issues, including: Rate limiting Throttling Network delays Intermittent errors It’s free, open source, and designed for developers who want to catch performance bottlenecks before they reach production. Installing DevProxy Follow the official setup guide here: DevProxy Installation Documentation Basic Usage Run DevProxy with the default configuration: devproxy It will begin intercepting all HTTP/HTTPS requests. Tip: Start DevProxy before running your own code — otherwise, it won’t capture the traffic. Simulating Microsoft Graph Throttling Here’s how to configure DevProxy to reproduce Microsoft Graph and SharePoint throttling issues locally. 1. Create a devproxy.json configuration file This is the exact configuration I used when testing my problematic parallel code: { "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/rc.schema.json", "rate": 25, "plugins": [ { "name": "RetryAfterPlugin", "enabled": true, "pluginPath": "~appFolder/plugins/dev-proxy-plugins.dll" }, { "name": "RateLimitingPlugin", "enabled": true, "pluginPath": "~appFolder/plugins/dev-proxy-plugins.dll", "configSection": "rateLimitingPlugin" }, { "name": "GraphRandomErrorPlugin", "enabled": true, "pluginPath": "~appFolder/plugins/dev-proxy-plugins.dll", "configSection": "graphRandomErrorPlugin" } ], "urlsToWatch": [ "https://graph.microsoft.com/v1.0/*", "https://graph.microsoft.com/beta/*", "https://graph.microsoft.us/v1.0/*", "https://graph.microsoft.us/beta/*", "https://dod-graph.microsoft.us/v1.0/*", "https://dod-graph.microsoft.us/beta/*", "https://microsoftgraph.chinacloudapi.cn/v1.0/*", "https://microsoftgraph.chinacloudapi.cn/beta/*", "https://*.sharepoint.*/*_api/web/GetClientSideComponents", "https://*.sharepoint.*/*_api/*", "https://*.sharepoint.*/*_vti_bin/*", "https://*.sharepoint-df.*/*_api/*", "https://*.sharepoint-df.*/*_vti_bin/*" ], "graphRandomErrorPlugin": { "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/graphrandomerrorplugin.schema.json", "allowedErrors": [ 429, 503, 504 ] }, "rateLimitingPlugin": { "$schema": "https://raw.githubusercontent.com/dotnet/dev-proxy/main/schemas/v0.27.0/ratelimitingplugin.schema.json", "costPerRequest": 2, "rateLimit": 120 }, "logLevel": "information", "newVersionNotification": "stable", "showSkipMessages": true, "showTimestamps": true } 2. Start DevProxy with your configuration devproxy --config-file devproxy.json Example of Problematic Code Here’s the snippet that caused my throttling nightmare. It uses Parallel.ForEachAsync to query SharePoint in bulk: // ❌ This will likely trigger throttling in production var customers = new ConcurrentBag(); await Parallel.ForEachAsync(departmentIds, async (departmentId, token) => { var clonedContext = _clientContext.Clone(_clientContext.Url); var query = Camlex.Query() .ViewFields(new CustomerDTO().ViewFields().ToArray().Append("DepartmentId")) .LeftJoin(x => x["DepartmentLookup"].ForeignList(DEPARTMENT_LIST_GUID)) .ProjectedField(x => x["DepartmentId"].List(DEPARTMENT_LIST_GUID).ShowField("ID")) .Where(x => x["DepartmentId"] == departmentId); var departmentCustomers = await SharePointService.GetItemsFromListByQuery( CUSTOMER_LIST_GUID, clonedContext, query); if (departmentCustomers?.Any() == true) { foreach (var customer in departmentCustomers) { customers.Add(customer); } } }); Why This Code Fails in Production No throttling safeguards — Multiple parallel requests overwhelm SharePoint. No retry logic — Requests fail instead of recovering. No request limiting — All departments are processed simultaneously. Silent failures — Errors are ignored without logging or fallback. The Better Way Instead of hard-coding fixes here, I recommend Bert Jansen’s detailed guide on throttling and rate limit handling: Throttling & Rate Limit Handling Patterns Key principles: Limit concurrency with SemaphoreSlim. Use exponential backoff for retries. Monitor rate limit headers to adjust requests dynamically. Handle errors explicitly to prevent silent failures. Conclusion DevProxy has become an essential tool in my Microsoft 365 development workflow. It helps me: Catch throttling issues before production. Test error handling and retry logic locally. Deliver applications that can survive real-world API limits. If I’d used DevProxy from the start, I could have avoided the last-minute throttling meltdown entirely. 💡 Pro tip: Make DevProxy part of your early development process, not your emergency toolkit. Additional Resources DevProxy GitHub Repository Microsoft Graph Throttling Guidelines DevProxy Documentation --- ### Efficient Multi-List Queries in CSOM: Using CAML Joins with CAMLEX Forfatter: Jeppe Spanggaard Dato: 2025-07-28 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-07-joining-multiple-lists-csom-caml/ The Problem: Multiple List Queries and Throttling When working with related data across multiple SharePoint lists, developers often fall into the trap of making multiple individual queries: // ❌ Bad approach - Multiple API calls var customers = context.Web.Lists.GetByTitle("Customers"); var orders = context.Web.Lists.GetByTitle("Orders"); var orderItems = context.Web.Lists.GetByTitle("OrderItems"); // Each query consumes 2 resource units var customerItems = customers.GetItems(camlQuery1); // 2 units var orderItems = orders.GetItems(camlQuery2); // 2 units var itemDetails = orderItems.GetItems(camlQuery3); // 2 units // Total: 6 resource units + processing overhead This approach has several issues: Higher resource consumption: Each query consumes 2 resource units per multi-item request Increased throttling risk: More API calls mean hitting limits faster Network overhead: Multiple round trips to SharePoint Complex data merging: Manual joining of results in C# How to fix it? I have worked alot with MSSQL where it is pretty easy to just join a table on a table on a table. But I did not know it was possible in CSOM. Eg. in the UI of SharePoint you can only expand one table - NOT multiple. But one day I deep dived into CAML and joins, and found out it was possible! Resource Unit Comparison: Let’s break down the actual cost difference. According to Microsoft’s throttling documentation, each multi-item query consumes 2 resource units. Multiple separate queries (❌ Bad approach): // Query 1: Get order items from main list var orderItems = ordersList.GetItems(query1); // 2 resource units // Query 2: Get customer details var customers = customersList.GetItems(query2); // 2 resource units // Query 3: Get order details var orderDetails = orderDetailsList.GetItems(query3); // 2 resource units // Total: 6 resource units + network overhead + manual C# joining Single join query (✅ Good approach): // One query with joins gets ALL the data var items = ordersList.GetItems(joinQuery); // 2 resource units total The math: Multiple queries: 2 units × 3 lists = 6 units Single join query: 2 units total Savings: 66% fewer resource units! This becomes even more significant when you consider that SharePoint throttling limits are measured in resource units per time window. With joins, you can query 3x more data within the same throttling limits. The Solution: CAML Joins CAML actually supports joining multiple lists in a single query! This means you can get data from related lists without multiple API calls. First, install CAMLEX via NuGet: Install-Package Camlex.Client.dll Then build your join query: var list = context.Web.Lists.GetByTitle("YourMainList"); CamlexNET.Interfaces.IQuery query = Camlex.Query(); query = query .LeftJoin(x => x["OrderTaskLookUp"].ForeignList(ListGuidOrderTasks)) .LeftJoin(x => x["OrderDetailLookUp"].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders)) .LeftJoin(x => x["CustomerLookUp"].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers)) .ProjectedField(x => x["CustomerNo"].List(ListGuidCustomers).ShowField("CustomerNo")) .ProjectedField(x => x["CustomerName"].List(ListGuidCustomers).ShowField("CustomerName")); var camlQuery = new CamlQuery(); camlQuery.ViewXml = query.ToString(); var items = list.GetItems(camlQuery); context.Load(items); context.ExecuteQuery(); Important: Your lists need to be connected via lookup columns for joins to work! Why CAMLEX Instead of Raw CAML? I don’t write raw CAML myself - it’s verbose and error-prone. Instead, I use CAMLEX because: Cleaner syntax: C# lambda expressions instead of XML IntelliSense support: Catch errors at compile time Easier for the next developer: Self-documenting code Less mistakes: No more XML typos or missing tags What else? One thing is to join multiple lists, but to filter on a value 4 lists away - That is neat! For example, filtering orders by the customer’s name, which is 2 lists away: CamlexNET.Interfaces.IQuery query = Camlex.Query(); query = query .Where(x => (string)x["CustomerName"] == "Contoso") // <---- Filtering on join field .LeftJoin(x => x["OrderTaskLookUp"].ForeignList(ListGuidOrderTasks)) .LeftJoin(x => x["OrderDetailLookUp"].PrimaryList(ListGuidOrderTasks).ForeignList(ListGuidOrders)) .LeftJoin(x => x["CustomerLookUp"].PrimaryList(ListGuidOrders).ForeignList(ListGuidCustomers)) .ProjectedField(x => x["CustomerNo"].List(ListGuidCustomers).ShowField("CustomerNo")) .ProjectedField(x => x["CustomerName"].List(ListGuidCustomers).ShowField("CustomerName")); var camlQuery = new CamlQuery(); camlQuery.ViewXml = query.ToString(); var items = ordersList.GetItems(camlQuery); This CAMLEX query automatically generates the following CAML XML - notice how complex the raw XML is compared to the clean C# syntax above: Contoso Imagine having to write and maintain that XML manually! This is exactly why CAMLEX is so valuable - you get all the power of CAML joins with readable C# syntax. Performance Benefits Before (Multiple queries): 🔴 6+ resource units 🔴 Multiple network calls 🔴 Complex C# merging logic After (Single join): ✅ 2 resource units only ✅ One network call ✅ Server-side joining Key Takeaways Use joins instead of multiple queries to reduce resource consumption CAMLEX makes CAML readable for you and the next developer You can filter on joined data even multiple lists away SharePoint UI limitations ≠ API limitations - joins work even if the UI doesn’t show it Common Issues & Solutions ❌ “List does not exist” error // Use list GUIDs instead of names for reliability .ForeignList(new Guid("12345678-1234-1234-1234-123456789012")) ❌ “Field not found” error // Use internal field names, not display names .ShowField("Title") // ✅ Internal name .ShowField("Customer Name") // ❌ Display name ❌ Join returns no data Verify lookup columns exist and are properly configured Check that you’re joining on the correct fields Ensure the lookup field contains valid IDs ❌ “Cannot project this field type” error Only specific field types can be included in ProjectedFields: ✅ Supported ProjectedFields types: Calculated (treated as plain text) ContentTypeId Counter Currency DateTime Guid Integer Note (one-line only) Number Text ❌ NOT supported in ProjectedFields: Multi-line text fields Rich text fields Choice fields Lookup fields (use joins instead) User/Person fields Managed metadata fields 💡 Pro tip: If you need data from unsupported field types, query them separately after getting your joined results. CAML joins are one of five techniques in The SharePoint CSOM Performance Playbook, which covers when a join is the right answer and when batching or change detection would serve you better. --- ### Microsoft Graph SDK Authentication in C#: Quick Start Guide Forfatter: Jeppe Spanggaard Dato: 2025-06-20 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-06-graph-sdk-authentication-csharp/ What is Microsoft Graph? Microsoft Graph is the unified REST API for Microsoft 365. Think of it as a single gateway to access data across SharePoint, Teams, OneDrive, Outlook, Entra ID, and more, all through one consistent API. Graph vs CSOM/PnP.Framework: Graph: Modern REST API, works across all Microsoft 365 services CSOM/PnP: SharePoint-specific, more SharePoint features available When to Use Graph SDK vs PnP.Framework Reach for the Graph SDK when you need Teams, OneDrive, Exchange or Entra ID data, when you want the built-in retry and batching, or when the feature spans several Microsoft 365 services. Reach for PnP.Framework when you need the SharePoint-specific surface: site templates, provisioning, taxonomy, search refiners, managed metadata, content types. Graph won’t help you with SharePoint classic features like master pages and web parts, its search is thinner than the SharePoint Search API, and it can’t talk to SharePoint Server on-premises at all. Which SDK Version Are You On? This is the part that costs people an afternoon, so it comes before the code. The .NET SDK was rewritten in v5. It moved to Kiota-generated clients, and .Request() was removed from every call. That one change means a v4 sample does not compile on v5, and a v5 sample does not compile on v4. Most of the samples you’ll find online, including the first version of this post, are v4. v6 is the current major, and v5 code compiles on it unchanged. I checked: the same file builds clean against 5.105.0 and 6.5.0. So there are really two dialects to care about, not three. # current: v5 syntax, v6 package dotnet add package Microsoft.Graph --version 6.5.0 # legacy: only if you're pinned to v4 already dotnet add package Microsoft.Graph --version 4.54.0 Pin it. Install-Package Microsoft.Graph with no version gives you the newest major, and if you then paste a v4 sample into it you get a wall of does not contain a definition for 'Request'. One thing to get out of the way: there is no Microsoft.Graph.Auth package. It never had a stable release and the namespace doesn’t exist in v4, v5 or v6. If a sample tells you to add using Microsoft.Graph.Auth;, that sample predates all of this. You want Azure.Identity. dotnet add package Azure.Identity Add Graph Permissions In your existing app registration, go to “API permissions” then “Add a permission” then “Microsoft Graph” then “Application permissions”: Sites.ReadWrite.All: SharePoint sites and lists access Click “Grant admin consent” after adding permissions, or every call comes back 403. The Code, v5 and v6 Same certificate and app registration as the PnP.Framework post. using Azure.Identity; using Microsoft.Graph; using Microsoft.Graph.Models; using System.Security.Cryptography.X509Certificates; class Program { private static readonly string TenantId = ""; private static readonly string ClientId = ""; private static readonly string CertificatePath = @"C:\Temp\cert\appcert.pfx"; private static readonly string CertificatePassword = ""; static async Task Main(string[] args) { var certificate = X509CertificateLoader.LoadPkcs12FromFile(CertificatePath, CertificatePassword); var options = new ClientCertificateCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, }; var credential = new ClientCertificateCredential(TenantId, ClientId, certificate, options); var scopes = new[] { "https://graph.microsoft.com/.default" }; var graphClient = new GraphServiceClient(credential, scopes); var org = await graphClient.Organization.GetAsync(); Console.WriteLine($"Connected to: {org?.Value?.FirstOrDefault()?.DisplayName}"); var site = await graphClient.Sites["root"].GetAsync(); var lists = await graphClient.Sites[site.Id].Lists.GetAsync(); Console.WriteLine("Lists in this site:"); foreach (var list in lists?.Value ?? []) { Console.WriteLine($"- {list.DisplayName}"); } var docLib = (lists?.Value ?? []).FirstOrDefault(l => l.DisplayName == "Documents"); if (docLib != null) { var content = System.Text.Encoding.UTF8.GetBytes("Hello from Graph SDK!"); var drive = await graphClient.Sites[site.Id].Lists[docLib.Id].Drive.GetAsync(); var file = await graphClient.Drives[drive.Id] .Items["root:/sample-document.txt:"] .Content .PutAsync(new MemoryStream(content)); Console.WriteLine($"Uploaded file: {file?.Name}"); } } } What’s happening here? ClientCertificateCredential comes from Azure.Identity, not from Graph. It’s a TokenCredential, and GraphServiceClient takes one directly. No auth provider, no handler, no wrapper. .default as the scope is what tells Entra ID to issue every application permission you consented to. You don’t list individual scopes for app-only auth. No .Request() anywhere. In v5 the request builder ends in the verb, so it’s .GetAsync(), .PutAsync(), .PostAsync() straight off the path. Collections come back wrapped. It’s lists.Value, not lists, and everything is nullable, so the compiler will nag you until you handle it. That nagging is correct: Sites["root"] really can hand you back a null. X509CertificateLoader is .NET 9 and later. On .NET 8 or earlier use new X509Certificate2(path, password), which still works but is marked obsolete (SYSLIB0057) on newer targets. The Same Code, v4 If you’re pinned to 4.54.0, the credential setup is identical and only the calls change. using Azure.Identity; using Microsoft.Graph; using System.Security.Cryptography.X509Certificates; var certificate = new X509Certificate2(CertificatePath, CertificatePassword); var options = new ClientCertificateCredentialOptions { AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, }; var credential = new ClientCertificateCredential(TenantId, ClientId, certificate, options); var scopes = new[] { "https://graph.microsoft.com/.default" }; var graphClient = new GraphServiceClient(credential, scopes); var org = await graphClient.Organization.Request().GetAsync(); Console.WriteLine($"Connected to: {org.First().DisplayName}"); var site = await graphClient.Sites["root"].Request().GetAsync(); var lists = await graphClient.Sites[site.Id].Lists.Request().GetAsync(); foreach (var list in lists) { Console.WriteLine($"- {list.DisplayName}"); } var docLib = lists.FirstOrDefault(l => l.DisplayName == "Documents"); if (docLib != null) { var content = System.Text.Encoding.UTF8.GetBytes("Hello from Graph SDK!"); var drive = await graphClient.Sites[site.Id].Lists[docLib.Id].Drive.Request().GetAsync(); var file = await graphClient.Drives[drive.Id].Root .ItemWithPath("sample-document.txt") .Content .Request() .PutAsync(new MemoryStream(content)); Console.WriteLine($"Uploaded file: {file.Name}"); } Three differences worth naming, because they’re the ones that break a paste: v4 v5 and v6 Call shape .Request().GetAsync() .GetAsync() Collections iterate the result directly iterate result.Value Upload path .Root.ItemWithPath("x") .Items["root:/x:"] GraphServiceClient(credential, scopes) is the same in both, which is the good news: the authentication half of this post doesn’t change between versions. Only the calls do. Key Differences from PnP.Framework Operation PnP.Framework Graph SDK Get Lists context.Web.Lists graphClient.Sites[id].Lists Upload File docLib.RootFolder.Files.Add() drive.Items["root:/name:"].Content.PutAsync() Authentication AuthenticationManager ClientCertificateCredential Graph advantages: access to Teams, OneDrive and Exchange data with the same client. PnP advantages: more SharePoint-specific features like content types and site columns. Troubleshooting The type or namespace name 'Auth' does not exist in the namespace 'Microsoft.Graph': you copied a sample with using Microsoft.Graph.Auth;. Delete the line, add Azure.Identity. 'OrganizationRequestBuilder' does not contain a definition for 'Request': v4 code on a v5 or v6 package. Drop the .Request() calls. SYSLIB0057: the X509Certificate2 constructor is obsolete on .NET 9 and later. Use X509CertificateLoader.LoadPkcs12FromFile. “Insufficient privileges”: permissions added but admin consent not granted. “Certificate not found”: check the path and password before you suspect anything cleverer. Next Steps You now have the Graph SDK working with your existing app registration, on whichever major version you’re pinned to. To reach more of Microsoft 365, add the matching application permission: User.Read.All for user data Group.Read.All for Teams and groups Files.ReadWrite.All for broader file operations Mail.Read for Exchange data If you’re still on v4, the upgrade is mostly mechanical: delete every .Request(), add .Value where you iterate, and fix the upload path. The authentication code you just wrote carries over untouched. --- ### PnP.Framework Authentication in C#: A Beginner's Guide Forfatter: Jeppe Spanggaard Dato: 2025-06-03 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2025-06-pnp-framework-authentication-csharp/ What does PnP.Framework actually do for authentication? It gets you a ClientContext without a human clicking a login prompt. That’s the whole job. You hand PnP.Framework.AuthenticationManager a client ID, a certificate and a tenant ID, and it deals with the OAuth flow so you don’t have to. Everything after that is ordinary CSOM. Which is exactly what you want for a background service or a console app - anything that has to reach SharePoint at three in the morning with nobody around to sign in. Here’s the setup end to end. If you arrived here because an app-only client secret suddenly stopped working: that’s Azure ACS. It was retired for SharePoint Online on 2 April 2026, with no extension, and certificate-based authentication against a Microsoft Entra app registration is the replacement. That’s what this post sets up - so this is the destination, not a detour. App registration Go to https://portal.azure.com and search for “App registrations”: Click “New registration” and fill out the name, and leave the “Supported account types” to default for now (Default: Accounts in this organizational directory only (xxx - Single tenant)) And now just press “Register” Just like so, you have created your app registration! Take note of the Application (client) ID and Directory (tenant) ID from the overview page - you’ll need these values later in your C# code. Generate certificate We need a certificate on the app registration, and in our code to authenticate our C# application. Certificates are more secure than client secrets because they’re harder to extract and can be managed through your organization’s PKI infrastructure. Open PowerShell as Administrator and run the following commands: $cert = New-SelfSignedCertificate ` -Subject "CN=MyPnPAppCert" ` -CertStoreLocation "cert:\CurrentUser\My" ` -KeyExportPolicy Exportable ` -KeySpec Signature ` -KeyLength 2048 ` -NotAfter (Get-Date).AddYears(3) $plainText = "MySuperStrongPassword!" $secureString = ConvertTo-SecureString $plainText -AsPlainText -Force Export-PfxCertificate ` -Cert $cert ` -FilePath "C:\Temp\cert\pnpappcert.pfx" ` -Password $secureString Export-Certificate ` -Cert $cert ` -FilePath "C:\Temp\cert\pnpappcert.cer" This script creates a self-signed certificate that’s valid for 3 years. The certificate gets stored in your personal certificate store, and we export both the .pfx file (containing the private key) and the .cer file (public key only). Make sure the C:\Temp\cert\ directory exists before running this script. Upload certificate We need to upload the generated certificate - the .cer file (NOT the .pfx), to the app registration. The .cer file contains only the public key, while the .pfx contains the private key that should never be shared. Navigate to your app registration in Azure Portal, go to “Certificates & secrets”, then click “Upload certificate”: After uploading, you’ll see the certificate listed with its thumbprint. The thumbprint can be useful for identifying which certificate to use if you have multiple certificates. Add permissions Our app registration needs permission to be able to do stuff. There are two types of permissions: Delegated and Application. The differences between them are: Delegated permissions: Your app acts on behalf of a signed-in user. The app can only access what the user has access to. Application permissions: Your app runs as itself without a signed-in user. The app has access based on what you grant it. For this example we’ll use the SharePoint permission “Sites.FullControl.All” as an application permission, which gives our app full access to all SharePoint sites. After adding the permission, don’t forget to click “Grant admin consent” - this is crucial! Without admin consent, your app won’t be able to use the permissions even if they’re assigned. Now our app registration is setup and ready to be used in our C# code. C# Implementation Now let’s write the C# code to authenticate and connect to SharePoint using our certificate. I’ll show you a complete working example that you can use as a starting point for your own projects. First, create a new Console Application in Visual Studio and install the PnP.Framework NuGet package: https://www.nuget.org/packages/PnP.Framework/1.18.0 You can install it via Package Manager Console: Install-Package PnP.Framework Here’s a complete example that connects to SharePoint and demonstrates several common operations: using PnP.Framework; using System.Security.Cryptography.X509Certificates; class Program { private static readonly string TenantId = "your-tenant-id"; private static readonly string ClientId = "your-client-id"; private static readonly string CertificatePath = @"C:\Temp\cert\pnpappcert.pfx"; private static readonly string CertificatePassword = "MySuperStrongPassword!"; private static readonly string SiteUrl = "https://yourtenant.sharepoint.com/sites/yoursite"; static async Task Main(string[] args) { try { // Load the certificate var certificate = new X509Certificate2(CertificatePath, CertificatePassword); // Create authentication manager var authManager = new AuthenticationManager(ClientId, certificate, TenantId); // Get context using var context = authManager.GetContext(SiteUrl); // Load web properties context.Load(context.Web, w => w.Title, w => w.Url); await context.ExecuteQueryAsync(); Console.WriteLine($"Connected to: {context.Web.Title}"); Console.WriteLine($"Site URL: {context.Web.Url}"); // Example: Get all lists context.Load(context.Web.Lists, lists => lists.Include(l => l.Title, l => l.ItemCount)); await context.ExecuteQueryAsync(); Console.WriteLine("\nLists in this site:"); foreach (var list in context.Web.Lists) { Console.WriteLine($"- {list.Title} ({list.ItemCount} items)"); } // Example: Create a new list await CreateSampleList(context); // Example: Upload a document await UploadDocument(context); } catch (Exception ex) { Console.WriteLine($"Error: {ex.Message}"); } } private static async Task CreateSampleList(ClientContext context) { try { var listCreationInfo = new ListCreationInformation { Title = "Sample List", TemplateType = (int)ListTemplateType.GenericList }; var list = context.Web.Lists.Add(listCreationInfo); context.Load(list); await context.ExecuteQueryAsync(); Console.WriteLine($"\nCreated list: {list.Title}"); } catch (Exception ex) { Console.WriteLine($"List creation error: {ex.Message}"); } } private static async Task UploadDocument(ClientContext context) { try { var docLib = context.Web.Lists.GetByTitle("Documents"); var fileCreationInfo = new FileCreationInformation { Content = System.Text.Encoding.UTF8.GetBytes("Hello from PnP Framework!"), Url = "sample-document.txt", Overwrite = true }; var uploadFile = docLib.RootFolder.Files.Add(fileCreationInfo); context.Load(uploadFile); await context.ExecuteQueryAsync(); Console.WriteLine($"\nUploaded file: {uploadFile.Name}"); } catch (Exception ex) { Console.WriteLine($"File upload error: {ex.Message}"); } } } Understanding the Code Let’s break down what this code does: Certificate Loading: We load the .pfx file with the password we set earlier Authentication Manager: PnP.Framework.AuthenticationManager handles the OAuth flow using the certificate. The overload used here takes (clientId, certificate, tenantId); there are others for secrets, interactive login and loading the certificate from the store by thumbprint Context Creation: GetContext(siteUrl) gets a ClientContext object that represents our connection to SharePoint Basic Operations: Shows how to read site properties, list all lists, create a new list, and upload a document The beauty of PnP.Framework is that it abstracts away all the complex authentication details. Once you have the ClientContext, you can use it just like the regular SharePoint CSOM (Client Side Object Model). Important Notes Replace your-tenant-id, your-client-id, and the site URL with your actual values from Azure Portal The certificate path should point to your .pfx file (not the .cer file) Make sure your app has the necessary permissions granted and admin consent given Store sensitive information like certificates and passwords securely in production (use Azure Key Vault or similar) Consider using certificate thumbprints instead of file paths in production environments The using statement ensures proper disposal of the ClientContext Production Considerations For production applications, consider these improvements: // Load certificate from certificate store instead of file var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadOnly); var certificate = store.Certificates.Find(X509FindType.FindByThumbprint, "your-cert-thumbprint", false)[0]; store.Close(); // Use configuration instead of hardcoded values var tenantId = Configuration["Azure:TenantId"]; var clientId = Configuration["Azure:ClientId"]; Troubleshooting Certificate auth fails in a small number of ways, and the error messages are unhelpfully similar. Here’s the triage order, cheapest check first. The remote server returned an error: (401) Unauthorized. (also seen as Connect-PnPOnline : The remote server returned an error: (401) Unauthorized.) Work down this list in order - the first two account for most of them: Admin consent was never granted. The permission is listed on the app registration but nobody approved it. Azure Portal shows this clearly: the permission needs to read “Granted for [Your Organization]”, not just be present. The tenant has custom app authentication disabled. This one is nasty because nothing about the error hints at tenant configuration - your app registration is perfect and it still 401s. The switch is Set-SPOTenant -DisableCustomAppAuthentication $true to block it, $false to allow it again. Wrong token audience. A Microsoft Graph token does not work against SharePoint’s /_api, and vice versa. SharePoint wants https://.sharepoint.com/.default. The certificate isn’t actually associated with the app registration. Check you uploaded the .cer and that the thumbprint in the portal matches the certificate your code is loading. Attempted to perform an unauthorized operation. and Access denied. You do not have permission to perform this action or access this resource. You authenticated fine - the token is good, the operation is not allowed. Usually the permission is narrower than the thing you’re doing (Sites.Read.All when you need write), or the site is locked down beyond what tenant-level consent covers. Access is denied. (Exception from HRESULT: 0x80070005 (E_ACCESSDENIED)) Same family, thrown from deeper in CSOM. Common on site-scoped operations and on template application, where one handler in the template needs a permission the rest of the run didn’t. Microsoft.SharePoint.Client.ServerException: No User or App Context found The ClientContext was created without a working authentication path. Check that GetContext actually returned before you used it, and that the certificate loaded. The sign-in name or password does not match one in the Microsoft account system. This one lies. It usually has nothing to do with the password. It’s what you get from SharePointOnlineCredentials when MFA is enabled, when Conditional Access blocks legacy authentication, or when you’re on .NET Standard where that class doesn’t exist at all. The fix isn’t a better password, it’s the app-only certificate setup on this page. Cryptography Next Generation (CNG) is not supported on this platform. The .pfx was generated by New-SelfSignedCertificate on Windows and is now being loaded somewhere that isn’t - a Linux build agent, or a container. Regenerate it with OpenSSL for that platform. Certificate not found: Make sure the certificate path is correct and the password matches what you used when creating it. You can verify the certificate exists by checking it in the Windows Certificate Manager (certmgr.msc). Tenant ID issues: You can find your tenant ID in the Azure Portal under Azure Active Directory > Properties. It’s also visible in the app registration overview page. Certificate validation errors: If you’re using a self-signed certificate in a corporate environment, your organization’s security policies might block it. Consider using a certificate issued by your internal CA. Testing Your Setup Before diving into complex operations, test your connection with this minimal code: try { var certificate = new X509Certificate2(CertificatePath, CertificatePassword); var authManager = new AuthenticationManager(ClientId, certificate, TenantId); using var context = authManager.GetContext(SiteUrl); context.Load(context.Web, w => w.Title); await context.ExecuteQueryAsync(); Console.WriteLine($"Success! Connected to: {context.Web.Title}"); } catch (Exception ex) { Console.WriteLine($"Connection failed: {ex.Message}"); } Wrapping Up You now have a working setup for authenticating with SharePoint using PnP.Framework and certificates. This method is perfect for background services, console applications, or any scenario where you need unattended access to SharePoint. The certificate-based authentication is more secure than using client secrets since certificates are harder to compromise and can be managed through your organization’s certificate infrastructure. In my next post, I’ll show you how to perform common SharePoint operations like creating lists, uploading files, and managing permissions using this authentication setup. --- ### Send SharePoint list item attachments by mail using Power Automate Forfatter: Alexander Henkel Dato: 2025-04-02 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-04-powerautomate-send-list-item-attachments/ Learn how to send SharePoint list item attachments by e-mail, using Power Automate and JSON Step 1 Create a new automated cloud flow and select the trigger action for a selected item Insert Email as input Step 2 Initialize a variable as an array and name it attachments Step 3 We want to know which attachments we have available in the list item. Insert an get attachments action. The Id should be from our trigger action Step 4 We want to append the content of each attachment to our array To do this, insert an apply to each action, and insert the body from get attachments Step 5 Insert an get file content action inside the apply to each, and use the Id from get attachments, in the file identifier Step 6 Insert an append to array action and insert below code. { "Name": @{items('Apply_to_each')?['DisplayName']}, "ContentBytes": @{body('Get_file_content')} } Step 7 Outside the apply to each action insert an send an email action. Click the switch to array icon, and insert the variable attachments To should be the email input from the trigger action The flow is ready This is how the entire flow looks like Start flow from a button We can start a flow from a button on the list. Create a new text column and insert the following JSON code in format this column { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px" }, "customRowAction": { "action": "executeFlow", "actionParams": "{\"id\":\"\", \"headerText\":\"Send mail with attachments\",\"runFlowButtonText\":\"Send mail\"}" }, "children": [ { "elmType": "span", "attributes": { "class": "ms-bgColor-themePrimary ms-fontWeight-bold ms-bgColor-themeDarker--hover" }, "style": { "height": "30px", "width": "2x", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px", "cursor": "pointer" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Mail", "class": "ms-fontColor-themePrimary ms-fontColor-themeDarker--hover ms-font-m", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "8px", "margin-left": "10px" } }, { "elmType": "span", "style": { "margin-right": "10px" }, "txtContent": "Send mail" } ] } ] } Replace with your flow identifier. You get the flow identifier from the export menu Copy the entire identifier Finally we can run our flow, and receieve an e-mail with the list attachments ‎ --- ### Create a dialog with user inputs in a Model Driven App Forfatter: Alexander Henkel Dato: 2025-03-23 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-03-model-driven-app-dialogue-copy/ Learn how to create a dialog with user inputs, when a button is clicked in a Model Driven App The goal In this blog post we will create a dialog that can handle user inputs. When the user clicks a button we will be able to Patch user inputs to a table Create a note in the timeline Reuse our code for other dialogs. I expect that you have some experience with Model Driven Apps when following this blog post Tables We will create two new tables, that can handle tickets, and a related table that can handle the status of the table. Table 1 - Ticket Title Description Priority* Ticket status* MainColumn TextArea OptionSet OptionSet Priority Low Medium High Critical Ticket Status Open Closed Reopened Table 2 - TicketClosed Title Regarding Ticket status Satisfaction* MainColumn Lookup (Ticket) OptionSet OptionSet Satisfaction Happy Neither or Sad Once the tables have been setup, go ahead and create your Model Driven App form This is how mine looks Add a custom page The dialog will be created in Power Apps studio as a Custom Page Step 1 In your app click + Add page and select Custom page Step 2 Click Create custom page and give it a name. I have called mine Close ticket dialog Creating a responsive dialog We will create a responsive dialog, so that that we have a “boiler plate” app we can use for other dialogs. Before we start working in the app we will add 3 tables in the Data section Go ahead and add the following 3 tables Step 1 In the App section insert the following code MinScreenHeight If(IsBlank(Param("recordId")), 300, "" - 53 //Setting height to 300 px. when working in the Power Apps Studio MinScreenWidth If(IsBlank(Param("recordId")), 500, "") //Setting width to 500 px. when working in the Power Apps Studio OnStart If( IsBlank(Param("recordId")), Set(recordId, First(Tickets).Ticket); Set(recordItem, LookUp(Tickets, (First(Tickets).Ticket = GUID(recordId))) ), Set(recordId, Substitute(Substitute(Param("recordId"), "{", ""), "}", "")) && Set(recordItem, LookUp(Tickets, (Ticket = GUID(recordId))))); //Getting the first record from the ticket table when working in the Power Apps Studio, //else we are setting recordID to the selected record in the Model Driven App Step 2 Insert a vertical container with the following properties DropShadow: DropShadow.None Height: Parent.Height LayoutAlignItems: LayoutAlignItems.Stretch LayoutDirection: LayoutDirection.Vertical PaddingBottom: 10 PaddingLeft: 10 PaddingRight: 10 PaddingTop: 10 Width: Parent.Width DropShadow: none X: 0.00 Y: 0.00 Step 3 Insert a horizontal container with the following properties Flexible height: Off (in the properties panel) Height: 50 Minimum width: 0 (in the properties panel) Drop shadow: None (in the properties panel) Copy the container 3 times, and rename your containers Your tree view should now look something like this Step 4 In the ctn_CloseAs container insert a label with the following properties AlignInContainer: AlignInContainer.Center Text: "Close as" Width: 100 Step 5 Insert a Dropdown Modern Control with the following properties AlignInContainer: AlignInContainer.Center Items: Choices(TicketCloseds[@'Ticket status']) DefaultSelectedItems: ["Closed"] DisplayMode: DisplayMode.View **If you don't get any items, make sure that "ticket status" is the actual name of your choice column** Step 6 In the ctn_Regarding insert a Dropdown with the following properties DefaultSelectedItems: recordItem DisplayMode: DisplayMode.View Items: recordItem Don’t forget to select a field for your dropdown, otherwise it will be blank Step 7 Our ctn_Button will have a some different properties due to the alignment of the buttons LayoutGap: 10 LayoutJustifyContent: LayoutJustifyContent.End Create a new button with the following properties OnSelect: Back() Text: "Cancel" Step 8 Create a new button with the text Close ticket In the OnSelect property, insert the below code //Change status of the record to "Closed" Patch( Tickets, recordItem, {'Ticket status': 'Ticket status (Tickets)'.Closed} ); //Set the variable "lookupRecord" to see if the record has been closed earlier //We are looking for a record in the TicketsClosed table Set(lookupRecord, LookUp(TicketCloseds, Regarding.Ticket = recordItem.Ticket)); If( //if no record exists create a new one in the TicketsClosed table IsBlank(lookupRecord), Patch( TicketCloseds, Defaults(TicketCloseds), { 'Ticket status': 'Ticket status (TicketCloseds)'.Closed, Title: recordItem.Title, Regarding: recordItem, Satisfaction: Dropdown_satisfaction.Selected.Value }), //if record exists, update record in the TicketsClosed table Patch(TicketCloseds, lookupRecord, { 'Ticket status': 'Ticket status (TicketCloseds)'.Closed, Title: recordItem.Title, Regarding: recordItem, Satisfaction: Dropdown_satisfaction.Selected.Value } )); //Create a note Relate( recordItem.Notes, Patch( Notes, Defaults(Notes), { Title: "Ticket is closed", Description: Dropdown_satisfaction.Selected.Value, 'Is Document':'Is Document (Notes)'.No } ) ); //Navigate to the current record to refresh the form Navigate(recordItem) Our dialog is now ready to be published and added to our Model Driven App Inside the app where we created the custom page we must now add the custom page we just created, and publish the app. Otherwise the next steps won’t work Add a custom button (close ticket) Step 1 Navigate to edit the commandbar Step 2 Create a new button with the text Close ticket. For the visibility rule, we want to make sure that the button is only visible when the ticket is open, so we will use the following visibility rule If(Self.Selected.Item.'Ticket status' <> 'Ticket status (Tickets)'.Closed, true, false) Step 3 The functionality of the button will be created by adding a JavaScript library. The code is function main( formContext, selectedEntityTypeName, FirstSelectedItemId, SelectedControlSelectedItemCount, DialogWidth, DialogHeight, DialogTitle, CustomPageName ) { // Get the record GUID if we have a formContexst and remove the {} from the GUID const recordGUID = formContext?.data?.entity?.getId()?.replace(/[{}]/g, ""); const entityId = FirstSelectedItemId ?? recordGUID; const SelectedItem = SelectedControlSelectedItemCount ?? 0; // Dialog const pageInput = { pageType: "custom", //Custom page type name: CustomPageName, //Unique name of custom page entityName: selectedEntityTypeName, //Entity Name recordId: FirstSelectedItemId ?? recordGUID, //Record from listview or form }; console.log(pageInput); // Update item to check if the button is pressed in a form or list view // Custom Pages can only get EntityId and EntityName as parameters // const data = { // ahe_buttonselectedinform: SelectedItem === 0 ? "true" : "false", // }; // Xrm.WebApi.updateRecord(selectedEntityTypeName, entityId, data); // Dialog Options const navigationOptions = { target: 2, position: 1, width: { value: DialogWidth, unit: "px" }, height: { value: DialogHeight, unit: "px" }, title: DialogTitle, }; // Open the dialog Xrm.Navigation.navigateTo(pageInput, navigationOptions) .then(function () { // Called when the dialog close }) .catch(function (error) { // Handle error alert("CANCEL"); }); } Some of the code is commented out. We will use that later in another post. In the function name write main and add the following 8 parameters Parameter 1: PrimaryControl Parameter 2: selectedEntityTypeName Parameter 3: FirstSelectedItemId Parameter 4: SelectedControlSelectedItemCount Parameter 5: (the width of the dialog in px) Number 500 Parameter 6: (the height of the dialog in px) Number 275 Parameter 7: (the headline of the dialog) String Close ticket Parameter 8: (the name of the custom page) ahe_closeticketdialog_1d77b The custom page name for parameter 8 can be taken from the solution under pages Here is how my setup looks like Accepting notes Before we can add notes to our custom table, we need to enable notes in the properties of the table Reopen ticket To reopen the ticket you can use almost the same logic as above. Create a new custom page, and reuse the app we just made. The same JavaScript library can also be reused. ‎ --- ### Create a searchable dictionary with PnP Modern Search in SharePoint Forfatter: Alexander Henkel Dato: 2025-03-01 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-03-pnpsearch-dictionary/ Learn how to create a searchable dictionary with PnP Modern Search What will we build? In this blog post we will build a dictionary with a title, links and a description that can be unfolded Installing PnP Modern Search If you have not already installed PnP Modern Search on your tenant, you can follow this guide SharePoint list After installation create a new SharePoint list called dictionary list Add the following fields to your list DictionaryLink (text) DictionaryDescription (text) Add a few items to your list Setting up PnP Modern Search Step 1 Create a new page and add the following two webparts PnP - Search Box PnP - Search Results Step 2 Select the Search Box webpart and select the properties icon. Insert Search in dictionary… in the Placeholder text to display in search box and change the property Reset query on clear to On Step 3 Select the Search Result webpart and select the properties icon. Select under Available data sources SharePoint Search Go to Query template and insert the following query: {searchTerms}* path:"https://YOURTENANT.sharepoint.com/sites/YOURSITENAME/Lists/YOURLISTNAME/" contentclass:STS_ListItem Remember to change in the example above yourtenant, yoursitename and yourlistname The search crawl might take some time, so if you don’t get any immidiate results, just try again a bit later. You might have to re-index your site, which you can read more about further below Your setup should now look something like below Setting up a RefinableString Before we go any further with setting up our dictionary we need to go to the admin center to setup a RefinableString for our dictionary list The search center can be accessed using the below link (replace YOURTENANT in the link) https://YOURTENANT-admin.sharepoint.com/_layouts/15/searchadmin/TA_SearchAdministration.aspx Click on Manage Search Schema Under Filter write RefinableString and click the green arrow (note that everything you do from here, have patience, the search admincenter is slooooow) Find a RefinableStringXX that how no Mapped Crawled properties like RefinableString09 in my example below and click on the property name to open it. Scroll to the bottom of the page and click add mapping In the crawled property selection window write dictionarylink -> click find and select the property ows_DictionaryLink (if more options show up, only select the one beginning with ows) Click OK and in the bottom of the page click OK Select the next available RefinableString, and do the same thing again, but this time for dictionarydescription Reindex your list You can either wait 48 hours for your RefinableString to be “attached” to your items or we can request a reindex of the list. Go to your list and click the settings icon and select List settings In the list settings select Advanced settings and click the Reindex List button Setup the popup window as the picture below and click Reindex list After you have reindexed your list it can still take a long time before the RefinableStrings are ready to work with, but my experience is to wait for about 30 minutes. Setting up PnP search results. Go back to the settings pane of the PnP Modern Search Result and go to the Selected properties and click the arrow. Scroll down and see if your RefinableStrings are available. If they are not there, wait a bit more! If they ARE available, select them Once the RefinableStrings have been selected, scroll up again and select Customize In the bottom of the page insert DictionaryLink and bind it to your RefinableString. Do the same thing with DictionaryDescription Changing the layout template In the property pane of the search result webpart, go to page 2 of 4 and select a Custom template**. Click on Edit results template and replace the entire code with below code
{{#if @root.properties.showResultsCount}} {{/if}}
{{#each data.items as |item|}}
{{/each}}
{{#if @root.properties.paging.showPaging}} {{#gt @root.data.totalItemsCount @root.properties.paging.itemsCountPerPage}} {{/gt}} {{/if}}
Setting up search inputs Currently you won’t get any results if you use the search box webpart. To connect the search result with your search input go to page 3 of 4 and change Use input query text to On and setup the options as below picture Congratz your now have a fully functional PnP Search Dictionary 🚀 Setting up “see all” In the properties pane we can control how many items we would like to show pr. page. If you look in the code above we have the possibility to create a link to another site What I usually setup with the See all is another page with the same search setup, but showing up to 500 results. The only thing you have to do here, is the remove the above code from this new page, so we don’t have another “See all” available for that page. You can also create a link directly to your list if you want to - Maybe with some fancy JSON view formatting ;) 🚀 Inspiration I found the inspiration to build my dictionary search result from the pnp-modern-search-layouts repository @ GitHub More specifically the Quick Links Look-alike Thank you Kasper Bo Larsen 🚀 ‎ --- ### Copy a file from one library to another with full version history in SharePoint using Power Automate Forfatter: Alexander Henkel Dato: 2025-02-03 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2025-02-powerautomate-copy-file-with-versions/ Learn how to copy a file from one library to another using Power Automate, with the complete version history of the file in SharePoint The challenge When copying a file from one library/site to another you won’t get the complete version history in SharePoint. Using a HTTP request called CreateCopyJobs we are able to copy the file containing the version history. The solutions Step 1 Create a new Instant cloud flow and select the trigger For a selected file Step 2 Add the action Get file properties and insert the ID from your trigger output Step 3 Add the action Send an HTTP request to SharePoint with the following options Method POST Uri \_api/site/CreateCopyJobs Headers Accept application/json; odata=nometadata Content-Type application/json; odata=verbose Body { "exportObjectUris": [ "@{outputs('Get_file_properties')?['body/{Link}']}" ], "destinationUri": "https://XYZ.sharepoint.com/sites/Blog/Copy%20library", "options": { "IgnoreVersionHistory": false, "IsMoveMode": false, "MoveButKeepSource": true, "AllowSchemaMismatch": true, "NameConflictBehavior": 1, "AllowSmallerVersionLimitOnDestination": true, "BypassSharedLock": true } } Replace destinationUri with your destination library. Be carefull not to introduce an / in the end of your URL Your HTTP request should look like this Start your flow This specific flow will be triggered for a selected file, so let’s add a button to our library. Create a new column called CopyFile and add the following JSON to the column. { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "customRowAction": { "action": "executeFlow", "actionParams": "{\"id\":\"FLOWIDENTIFIER\", \"headerText\":\"Copy file\",\"runFlowButtonText\":\"Copy file\"}" }, "style": { "display": "flex", "align-items": "center", "margin-top": "2px", "margin-bottom": "2px" }, "children": [ { "elmType": "a", "attributes": { "class": "ms-bgColor-themePrimary ms-bgColor-themeDarker--hover" }, "style": { "cursor": "pointer", "height": "30px", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Copy", "class": "ms-fontSize-16 ms-fontWeight-regular", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "5px", "margin-left": "8px" } }, { "elmType": "span", "style": { "margin-right": "8px" }, "txtContent": "Copy file", "attributes": { "class": "ms-fontWeight-bold" } } ] } ] } Replace FLOWIDENTIFIER with your own flow identifier. Getting the flow identifier You can get the flow identifier from the Export button in the top ribbon Be carefull that you don’t remove the \ when inserting your identifier The code should look like this "actionParams": "{\"id\":\"v1/Default-efadct70-cxsa-45b1-a688-8442bf7ce56c/7839e3b9-62c8-4130-44b6-a1e696ca4999\", \"headerText\":\"Copy file\",\"runFlowButtonText\":\"Copy file\"}" We can now copy a file from one library to another using Power Automate with full version history in SharePoint ‎ --- ### Export files as zip from SharePoint Forfatter: Jeppe Spanggaard Dato: 2024-12-01 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2024-12-download-multiple-files-from-sharepoint/ I have long been frustrated by the inability to download multiple files from SharePoint as a zip file via code, in the same way it’s possible through the user interface. When this suddenly became a requirement from a customer , I had to come up with a solution 💡. Naturally, the solution needed to be robust 💪 — I could use the endpoint that SharePoint itself utilizes, though it’s not officially documented, but I decided against taking that route 🚫. What was the solution then? 🤔 The solution came to me 🤔 while setting up the App Service Plan for my function—it struck me that I had 250GB of storage (App Service Premium Plan) available. I figured I could use this for something. I was initially unsure whether I had permissions to write to this storage, but after a quick test, I confirmed that I could easily write to it—and, of course, read from it again. 🚀 Whether it’s the best solution to the problem, I’m not sure 🤷‍♂️, but I know it works, and I have full control over it, ensuring it won’t suddenly disappear—unlike an unofficial endpoint might. 💡 Fetch the files 📥 When fetching the files, it’s, of course, important to minimize the number of calls to avoid throttling. The way I’ve attempted to prevent this is by using Graph batching, which allows me to bundle 20 requests into one. 📦 Example class public class FileInfoDTO { public string? FileName { get; set; } public string? RelativePath { get; set; } } Graph batching internal async Task DownloadFilesFromPathsToTempFolderAsync( List? fileInfo, string tempFolderPath, Site siteInfo, string? driveId) { IEnumerable chucked = fileInfo!.Chunk(20); foreach (FileInfoDTO?[] chunk in chucked) { await DownloadFilesFromPathsToTempFolderAsync(chunk, tempFolderPath, siteInfo, driveId); } } private async Task DownloadFilesFromPathsToTempFolderAsync( FileInfoDTO?[] fileInfos, string tempFolderPath, Site siteInfo, string? driveId) { using BatchRequestContent batchRequestContent = new BatchRequestContent(); Dictionary nameMapping = new Dictionary(fileInfos.Length); foreach (FileInfoDTO? fileInfo in fileInfos) { string requestId = batchRequestContent.AddBatchRequestStep( GraphClient.Sites[siteInfo.Id] .Drives[driveId] .Root .ItemWithPath(fileInfo.RelativePath) .Content.Request()); nameMapping[requestId] = fileInfo.FileName!; } BatchResponseContent batchResponse = await GraphClient.Batch.Request().PostAsync(batchRequestContent); Dictionary responses = await batchResponse.GetResponsesAsync(); foreach ((string requestId, HttpResponseMessage response) in responses) { if (!response.IsSuccessStatusCode && response.StatusCode != HttpStatusCode.Redirect) { throw new Exception($"Error while getting file content: {response.ReasonPhrase}"); } string filePath = Path.Combine(tempFolderPath, nameMapping[requestId]); if (response.StatusCode == HttpStatusCode.Redirect) { await DownloadFileFromRedirectAsync(response.Headers.Location, filePath); } else { await WriteContentToFileAsync(response.Content, filePath); } response.Content.Dispose(); } } You should be aware that the StatusCode returned may not always be 200 and can still be valid — many of my requests, for example, returned with a Redirect. 🔄 As a result, I had to implement handling for that as well. When I finally managed to get all my requests working to fetch the files, the next problem arose… How could I download the files and save them to my tempFolderPath without loading all the files into memory? If I did, I’d quickly run out of Memory. The solution to this turned out to be the following: Example class private async Task DownloadFileFromRedirectAsync(Uri? redirectUri, string filePath) { using HttpResponseMessage redirectResponse = await GraphClient.HttpProvider.SendAsync( new HttpRequestMessage(HttpMethod.Get, redirectUri)); redirectResponse.EnsureSuccessStatusCode(); await WriteContentToFileAsync(redirectResponse.Content, filePath); } private async Task WriteContentToFileAsync(HttpContent content, string filePath) { await using FileStream fileStream = new FileStream( filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, true); await using Stream contentStream = await content.ReadAsStreamAsync(); await contentStream.CopyToAsync(fileStream); } By using using statements and ReadAsStream, each file only resides in Memory for a very short time before it is disposed. ♻️💡 Example of a large file Return the ZIP file 🚀 So how did I implement it all in an endpoint? I did it as follows, and it even works locally on my PC, allowing me to test it easily. To avoid using too much Memory again, I return the ZIP file as a FileStreamResult. 🚀🗂️ [Function("DownloadFilesFromSharepoint")] public async Task DownloadFilesFromSharepoint( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "files/download")] HttpRequest req) { System.Guid guid = System.Guid.NewGuid(); string tempFolderPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), guid.ToString()); string zipFilePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{guid}.zip"); _logger.LogInformation($"Zip file path: {zipFilePath}"); List? fileInfo = await ...; if (!System.IO.Directory.Exists(tempFolderPath)) { System.IO.Directory.CreateDirectory(tempFolderPath); } await DownloadFilesFromPathsToTempFolderAsync(fileInfo, tempFolderPath); System.IO.Compression.ZipFile.CreateFromDirectory(tempFolderPath, zipFilePath); System.IO.Directory.Delete(tempFolderPath, true); return new FileStreamResult(new FileStream(zipFilePath, FileMode.Open), "application/zip") { FileDownloadName = "files.zip", EnableRangeProcessing = true, }; } However, there’s one thing I haven’t yet found the perfect solution for—namely, deleting my ZIP files. 🗑️ Since I return them as a FileStreamResult and don’t load them into Memory as a byte[], I can’t delete the files immediately. Instead, I handle this with a timer job afterward, which I don’t think is the best solution. 🤷‍♂️⏳ But again, it solved the customer’s problem, and they’re happy 😊, so I’m not planning to do much more about it. 🚀 TL;DR This post explores how to programmatically download multiple files from SharePoint as a zip file using C# and Microsoft Graph API. 🚀 Key takeaways: Storage Solution: Leveraged 250GB of App Service Premium storage to temporarily store files. Efficient API Usage: Minimized Graph API calls using batching to avoid throttling (bundling 20 requests into one). Memory Optimization: Used ReadAsStream and FileStreamResult to handle files efficiently without overloading RAM. ZIP File Handling: Created a ZIP file from the downloaded files and returned it via a streaming endpoint. Cleanup Challenge: Deleting ZIP files after returning them remains unresolved, currently handled via a timer job. It’s not perfect, but it works, and most importantly, it solved the customer’s problem. 😊 --- ### Create a custom button to open files in a SharePoint Library Forfatter: Alexander Henkel Dato: 2024-11-27 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-11-formatting-file-name/ Learn how to create a formatted button to open files in a SharePoint library Formatting For this formatted button we will utilize $FileRef, and $File_x0020_Type, to create our own formatted button that can open a file in a SharePoint library Here is what we are going to create The code Insert the following code in the column formatting field I suggest you use a text column type JSON code { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px", "display": "=if([$File_x0020_Type] == '', 'none', 'flex')" }, "children": [ { "elmType": "a", "attributes": { "href": "[$FileRef]", "target": "_blank", "class": "ms-bgColor-themePrimary ms-fontWeight-bold ms-bgColor-themeDarker--hover", "data-interception": "off" }, "style": { "height": "30px", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "FileSymlink", "class": "ms-fontSize-16 ms-fontWeight-regular", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "8px", "margin-left": "10px" } }, { "elmType": "span", "style": { "margin-right": "10px" }, "txtContent": "Open file" } ] } ] } A short explanation of the code Hiding the button We don’t want to show the button if the list does not have a file type (like a folder), therefore we are hiding the button if the file type is blank "display": "=if([$File_x0020_Type] == '', 'none', 'flex')" FileRef The $FileRef is a direct link to the file, so with anchor code "elmType": "a" we are simply creating a link with "href": "[$FileRef]" Icon You can change the icon name FileSymLink with any of the icons you like from this page ‎ --- ### Update list to use content type Forfatter: Jeppe Spanggaard Dato: 2024-11-10 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2024-11-update-list-to-use-content-type/ How To Add a Content Type After the Fact in SharePoint 💡 We’ve all tried creating a SharePoint list without using Content Types from the start. It seems like the quick solution, but it can lead to challenges later when restructuring the list. This post explains why using Content Types from the beginning is a smart move that can save time and hassle in the long run. This has been a recurring issue for me 😅 – countless times I’ve started a SharePoint list without Content Types, only to realize later that I needed the list for tasks like search 🔍 or advanced filtering. And, of course, it usually happens when the list is already full of data 📊 without any associated Content Type, making it even more challenging 🛠️ to get everything to work smoothly. ⚠️ Disclaimer: This will only work if the columns in the Content Type match those already present in the list. How Do I Fix It? 🤔 The first step is, of course, to create a content type. Once it’s created, I then export it as a template using the PnP PowerShell command Get-PnPSiteTemplate 💻. I make sure that only the content type is included in the XML file 📂. Once the PnP template is ready, a PowerShell script I created can be used to fix this issue – though it can’t handle all scenarios. The script includes the following variables, which need to be filled out: $siteUrl = "https://xxx.sharepoint.com/sites/xxx" $listName = "" $contentTypeName = "" $columnsToMap = @("...") # Replace with the actual column names $userFields = @("...") # Replace with actual user field names if needed $templatePath = "..\ContentTypeTemplate.xml"; What the script does is fairly simple 🛠️ – I load all rows with their values into memory 🧠, remove the columns ($columnsToMap) that need to be deleted 🗑️, then invoke the PnP template containing the content type. Finally, I reload all values into the new columns. Since they have the same names as the old columns, everything loads smoothly ✅. # Connect to SharePoint Online Connect-PnPOnline -Url $siteUrl -Interactive # Enable content type management on the list Set-PnPList -Identity $listName -EnableContentTypes $true Write-Host "Content types enabled on the list." # Backup data from old columns $items = Get-PnPListItem -List $listName -PageSize 2000 $backupData = @{} foreach ($item in $items) { $itemData = @{} # Backup old column data foreach ($column in $columnsToMap) { $itemData[$column] = $item[$column] } $backupData[$item.Id] = $itemData } # Remove old columns foreach ($column in $columnsToMap) { Remove-PnPField -List $listName -Identity $column -Force Write-Host "Removed old column:" $column } # Apply PnP Provisioning Template Invoke-PnPSiteTemplate -Path $templatePath Write-Host "PnP template applied to the site." # Get the content type $contentType = Get-PnPContentType -Identity $contentTypeName # Add the content type to the list Add-PnPContentTypeToList -List $listName -ContentType $contentType Write-Host "Content type added to the list." # Update items to the new content type and restore data to new columns foreach ($item in $items) { $itemId = $item.Id $itemData = $backupData[$itemId] # Handle user fields foreach ($userField in $userFields) { if ($itemData[$userField]) { $UserDto = $itemData[$userField]; write-host "User: " $UserDto.Email $itemData[$userField] = $UserDto.Email } } # Update item content type Set-PnPListItem -List $listName -Identity $itemId -Values @{"ContentTypeId" = $contentType.Id } # Restore data to new columns Set-PnPListItem -List $listName -Identity $itemId -Values $itemData Write-Host "Updated item ID:" $itemId } Write-Host "All items updated to the new content type and old columns removed." # Disconnect from SharePoint Online Disconnect-PnPOnline As mentioned earlier, this script doesn’t solve everything. For example, any views created with the old columns will no longer work, even if the new columns have the same name. So, there’s definitely room for improvement in my script. That said, the script has saved me quite a bit of manual work multiple times 🙌. --- ### How to display file thumbnails on hover with JSON formatting in SharePoint Forfatter: Alexander Henkel Dato: 2024-11-10 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-11-formatting-file-preview/ Learn how to create a custom JSON formatted column that shows a thumbnail preview of a file in a SharePoint library JSON formatting When storing a file in a SharePoint library, a thumbnail preview of the file is available. We can use that thumbnail to show a preview of the file, using JSON formatting. Step 1 Create a new text column called File preview Insert the following code into Column setting -> Format this column { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "display": "=if([$File_x0020_Size] == '0' || [$ContentType] == 'Folder', 'none', 'flex')" }, "children": [ { "elmType": "div", "style": { "margin-left": "25px" }, "attributes": { "iconName": "FileImage", "class": "ms-font-xl" }, "customCardProps": { "openOnEvent": "hover", "formatter": { "elmType": "div", "style": { "width": "450px", "height": "600px" }, "children": [ { "elmType": "img", "style": { "width": "100%", "height": "98%" }, "attributes": { "src": "=@thumbnail.440x590", "alt": "File thumbnail" } } ] }, "directionalHint": "rightCenter", "isBeakVisible": true } } ] } You can adjust the width/height of the customcardprop (the popup) if you like. You can also adjust the icon by replacing FileImage. You can find more columns here ‎ --- ### Refresh SharePoint list data fast in Power BI Forfatter: Alexander Henkel Dato: 2024-10-28 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-10-powerbi-faster-sharepoint-listdata/ Learn how to query data (more than 5000 items) faster from a SharePoint list in Power BI The challenge When refreshing data in Power BI from SharePoint, and using the standard SharePoint connecter, the refresh can be painfully slow. Also, when getting more than 5000 items, and trying to do an incremental refresh, you might also have encountered this error This dataset includes a dynamic data source. Since dynamic data sources aren’t refreshed in the Power BI service, this dataset won’t be refreshed. Let’s query data in a faster way that also works with scheduled refresh The solution A few things we need to consider We need to be able to get more than 5000 elements We need to avoid using dynamic data We might need to expand column types like people or lookup fields Step 1 In Power Query create a new blank query and click advanced editor Insert the following code (with needed adjustments, explained below the code snippet) let BaseURL = "https://yourtenant.sharepoint.com/sites/yoursitename/", ListName = "yourListName", ExpandColumns = "Author/Title,Editor/Title", Expand = "Author,Editor", CountItems = Json.Document(Web.Contents(BaseURL, [RelativePath = "_api/web/lists/GetByTitle('" & ListName & "')/items?$select=ID&$orderby=ID%20desc&$top=1", Headers = [Accept = "application/json"]]))[value]{0}[ID], StartID = List.Numbers(0, Number.RoundUp(CountItems / 5000), 5000), #"Converted to Table" = Table.FromList(StartID, Splitter.SplitByNothing(), null, null, ExtraValues.Error), EndID = Table.AddColumn(#"Converted to Table", "Custom", each [Column1] + 4999, type number), #"Renamed Columns" = Table.RenameColumns(EndID,{{"Column1", "StartID"}, {"Custom", "EndID"}}), #"Changed Type" = Table.TransformColumnTypes(#"Renamed Columns",{{"StartID", type text}, {"EndID", type text}}), selectedfields = "&$top=5000&$select=*," & ExpandColumns & "&$expand=" & Expand, ListItems = Table.AddColumn(#"Changed Type", "ListItems", each Json.Document(Web.Contents(BaseURL, [RelativePath = "_api/web/lists/GetByTitle('" & ListName & "')/items?$filter=(ID ge " & [StartID] & ") and (ID le " & [EndID] & ")&$top=5000&$select=*," & ExpandColumns & "&$expand=" & Expand, Headers = [Accept = "application/json"]]))[value]), #"Removed Other Columns" = Table.SelectColumns(ListItems,{"ListItems"}), #"Expanded ListItems" = Table.ExpandListColumn(#"Removed Other Columns", "ListItems") in #"Expanded ListItems" Change the following variables BaseUrl Change to your own site (don’t forget the last /) ListName: Change to your list name (the actual name, the one you see on your site) ExpandColumns: If you want to expand any columns like a people field, and get the title (name) or the EMail, you insert the expand columns here, like below: ExpandColumns = "Author/Title,Editor/Title,PersonColumn/EMail,PersonColumn/Title,LookupColumn/Title", Expand: Everytime you introduce a new column to expand, it is important you write the name here, like this: Expand = "Author,Editor,PersonColumn,LookupColumn", Step 2 You can now select the arrow icon and click expand Unclick use original column name as prefix and click OK Your list is now ready, and you can begin to expand your columns. Notice that everytime you introduce a new column, you need to go back to this step, and select the new column Note the that code above will only work if you have at least 1 expand column introduced. Further refinement The code above is currently selecting all the non-expandable columns in your list, so you can improve the code even further, by selecting ONLY the columns that you need In this part of the code, replace the asterix * in the code below, with the columns that you need selectedfields = "&$top=5000&$select=*," & ExpandColumns & "&$expand=" & Expand, Here is an example selectedfields = "&$top=5000&$select=ID,ProjectName,Title,Street," & ExpandColumns & "&$expand=" & Expand, And also here [EndID] & ")&$top=5000&$select=*," & ExpandColumns & "&$expand=" & Expand, Headers = [Accept = "application/json"]]))[value]), Like [EndID] & ")&$top=5000&$select=ID,ProjectName,Title,Street," & ExpandColumns & "&$expand=" & Expand, Headers = [Accept = "application/json"]]))[value]), Another thing to consider is if you really need to expand the people columns. Instead you could get the UserInformationList from SharePoint, and combine that table with the peopleId I got the inspiration to this solution from Peter Tadrous ‎ --- ### Create an interactive "review" button in SharePoint Forfatter: Alexander Henkel Dato: 2024-10-18 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-10-formatting-review/ Learn how to create an interactive button for users to confirm document reviews The challenge A company use SharePoint as an DMS/IMS (Document Management System / Integrated Management System) They must review their documents after a specific number of months, to comply to their ISO certification. We must create a solution where the system helps the users to be reminded when to review their documents. It should be easy to confirm that the document is reviewed, and set a new date for the next review. This is our final goal The solution Step 1 This example will be based on Document Sets, and if you are not familiar with them, you can read this excellent article by SharePoint Maven By following the guide above, turn on Document Sets for your SharePoint page, and add a Document Set as a new content type Add the following columns to your Document Set ReviewDate (Date) ReviewInXMonths (Number) ConfirmReview (Text) Step 2 When the user creates a new Document Set we will ask, in how many months they would like to review the documents in the Document Set. Since a specific date is not important, we just ask the user about the number of months, and we will let Power Automate calculate the first review date. Step 3 Create a new automated cloud flow and as your trigger select When a file is created (properties only) We only want our Power Automate to trigger when a new Document Set is created Open the settings panel of your trigger action and navigate to trigger conditions Insert the following code @equals(triggerBody()?['{ContentType}/Name'], 'Document Set') Step 4 Insert an update file properties action We know from the user in how many months they would like to review their document, so we can write an expression to calculate the next review date Insert the following expression in ReviewDate formatDateTime(addToTime(utcNow(), int(triggerOutputs()?['body/ReviewInXMonths']), 'Month'), 'yyyy-MM-dd') If your ReviewInXMonths field is not obligatory, you can expand the flow, so that if ReviewInXMonths is null then the flow should update ReviewDate with perhaps 12 months in the future. It is important that ReviewDate and ReviewInXMonths is newer left blank, otherwise our Click to mark reviewed button that we will create next, won’t work. Here is the entire flow Step 5 Navigate back to your SharePoint library and create a few Document Sets. You only need to fill out Name and ReviewInXMonths, and Power Automate will auto update the ReviewDate Step 6 When the user has reviewed the documents, we want them to be able to quickly confirm that the documents has been reviewed, and then let the system calculate the next review date. We will do that by adding a custom JSON formatted button. In the ConfirmReview insert the following code into format this column settings { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px" }, "children": [ { "elmType": "button", "customRowAction": { "action": "setValue", "actionInput": { "Confirmreview": "=padStart(toString(getDate(@now)),2,'0')+'-'+padStart(toString(getMonth(@now)+1),2,'0')+'-'+toString(getYear(@now))", "ReviewDate": "=getYear(addDays(@now, Number([$ReviewInXMonths]) * 30))+'-'+padStart(toString(getMonth(addDays(@now, Number([$ReviewInXMonths]) * 30))+1),2,'0')+'-'+padStart(toString(getDate(addDays(@now, Number([$ReviewInXMonths]) * 30))),2,'0')" } }, "attributes": { "class": "ms-bgColor-themePrimary ms-bgColor-themeDarker--hover ms-fontColor-white", "title": "=if(Number(@currentField) == 0, 'Click to mark reviewed', 'Reviewed on ' + @currentField.displayValue)" }, "style": { "border": "none", "cursor": "pointer", "display": "flex", "align-items": "center", "justify-content": "center", "height": "30px", "width": "175px", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "", "class": "ms-fontSize-16 ms-fontWeight-regular" }, "style": { "margin-right": "5px", "margin-left": "8px" } }, { "elmType": "span", "txtContent": "=if(Number(@currentField) == 0, 'Click to mark reviewed', 'Reviewed on ' + @currentField.displayValue)", "style": { "margin-right": "8px", "flex": "none" }, "attributes": { "class": "ms-fontSize-12 ms-fontWeight-bold" } } ] } ] } If you have not named your columns the same as I have, it is important that you change ConfirmReview and ReviewDate in this part of the code (internal names!) "Confirmreview": "=padStart(toString(getDate(....", "ReviewDate": "=getYear(addDays(@now, Number..... Step 7 Now we can click our new button, and the text in the button will change to todays date and the ReviewDate will be re-calculated to X months into the future ‎ --- ### Change status for a list item with JSON (choice column) Forfatter: Alexander Henkel Dato: 2024-10-04 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-10-formatting-change-status/ Learn how to create an interactive button to change status for a project/item in SharePoint The challenge If you have a list item (it could be a project) you probably have some stages for the specific project. Having a standard choice field might help you to control the stages, but the user first have to open the info panel, and there is a risk that they don’t know or select the wrong, next stage If the stages are not dynamic, we can use JSON formatting to create a button, that changes the status for the user The solution Step 1 Create a new choice column (not multi choice) Note that the first name you give the column will be the internal name which we need for the json code. I have created a new column with the name Stage and the following choices Initiation Planning Execution Closure Step 2 If you have made the exact same column, with the same choices as me, you can copy-paste the below code. If not, copy it into Visual Studio Code and make the nessesary changes as descriped in step 3 The JSON code { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "display": "=if(@currentField == '', 'none', 'flex')" }, "children": [ { "elmType": "div", "attributes": { "iconName": "ChromeBack", "class": "=if(@currentField != 'Initiation','ms-fontColor-neutralPrimary ms-fontColor-themePrimary--hover', 'ms-fontColor-neutralLight')" }, "style": { "cursor": "pointer", "padding-right": "4px", "padding-top": "4px" }, "customRowAction": { "action": "setValue", "actionInput": { "Stage": "=if(@currentField == 'Closure', 'Execution', if(@currentField == 'Execution', 'Planning', if(@currentField == 'Planning', 'Initiation', @currentField)))" } } }, { "elmType": "div", "children": [ { "elmType": "div", "style": { "flex-direction": "row", "display": "flex" }, "children": [ { "elmType": "div", "style": { "width": "100%", "height": "26px", "border-radius": "3px", "align-items": "center", "justify-content": "space-evenly", "margin": "5px" }, "attributes": { "class": "ms-bgColor-themePrimary ms-bgColor-themeDarker--hover ms-fontColor-white ms-fontSize-12 ms-fontWeight-bold" }, "children": [ { "elmType": "div", "style": { "margin": "5px" }, "txtContent": "@currentField" } ] } ] } ] }, { "elmType": "div", "children": [ { "elmType": "div", "attributes": { "iconName": "ChromeBackMirrored", "class": "=if(@currentField != 'Closure','ms-fontColor-neutralPrimary ms-fontColor-themePrimary--hover', 'ms-fontColor-neutralLight')" }, "style": { "cursor": "pointer", "padding-right": "4px", "padding-top": "4px" }, "customRowAction": { "action": "setValue", "actionInput": { "Stage": "=if(@currentField == 'Initiation', 'Planning', if(@currentField == 'Planning', 'Execution', if(@currentField == 'Execution', 'Closure', @currentField)))" } } } ] } ] } Step 3 To make the code work for your own column, and with your own choices do the following changes Change 1 Line 22 and 80 Change Stage the the internal name of your column Change 2 Line 12 change the word Initiation to the first choice/step in your stage Change 3 Line 70 change the word Closure to the last choice/step in your stage Change 4 Line 22 is your stage backwards so if the current stage is Closure then change to Execution, if Execution change to Planning and so on… "Stage": "=if(@currentField == 'Closure', 'Execution', if(@currentField == 'Execution', 'Planning', if(@currentField == 'Planning', 'Initiation', @currentField)))" Change 5 Line 80 is your stage the other way around. Again if the current stage is Initiation change to Planning, if current stage is Planning change to Execution and so on… "Stage": "=if(@currentField == 'Initiation', 'Planning', if(@currentField == 'Planning', 'Execution', if(@currentField == 'Execution', 'Closure', @currentField)))" We now have more control of the stages, and the user can now quickly change status by clicking the arrow. ‎ --- ### Create dynamic dates in Power BI with OData Forfatter: Alexander Henkel Dato: 2024-09-18 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-09-powerbi-odata-dynamic-table/ Learn how to query OData with dynamics dates to avoid one or more tables references a dynamic data source error The challenge We want to create an OData request, always returning data from the past 10 days. The below code works as intended in Power Query, but when we try to save our Dataflow or setup a scheduled refresh, we get the following error Can’t save dataflow One or more tables references a dynamic data source. let // Get the current date CurrentDate = DateTime.Date(DateTime.LocalNow()), // Calculate the date X days ago DateXDaysAgo = Date.AddDays(CurrentDate, -10), // Format the date in the required format FormattedDate = DateTime.ToText(DateTime.From(DateXDaysAgo), "yyyy-MM-ddTHH:mm:ss"), // Construct the OData URL with the formatted date ODataUrl = "https://odata.uniconta.com/odata/ProjectTransClient?$filter=Date ge datetime'" & FormattedDate & "'", // Fetch the data from OData Source = OData.Feed(ODataUrl, null, [Implementation = "2.0"]) in Source Here is the error I get The solution In this blog post I will use OData from an ERP system called Uniconta. You can read their documentation here The request we are building is similar to below, but we only want data from the past 10 days, from todays date. https://odata.uniconta.com/odata/ProjectTrancClient?$filter=Date ge datetime'2021-01-01T00:00:00' Step 1 Create a new blank query and insert below code in the advanced editor You can change the FilterQuery and BaseUrl to fit you own OData query. let // Define the base URL BaseUrl = "https://odata.uniconta.com/odata/ProjectTransClient", // Define query parameters CurrentDate = DateTime.Date(DateTime.LocalNow()), // Calculate the date (X) days ago DateXDaysAgo = Date.AddDays(CurrentDate, -10), // Format the date in the required format FormattedDate = DateTime.ToText(DateTime.From(DateXDaysAgo), "yyyy-MM-dd"), // Build filter query FilterQuery = "Date ge datetime'" & FormattedDate & "T00:00:00'", // Build options and query Options = [Headers = [#"accept" = "application/json"], Query = [#"$filter" = FilterQuery]], // Get RawData RawData = Web.Contents(BaseUrl, Options), Json = Json.Document(RawData), Navigation = Json[value] in Navigation Step 2 If you are prompted for credentials, click configure connection Step 3 Select To table in the List tools menu Step 4 Click on the expand icon and select OK Step 5 Your data is now ready, and you can save your dataflow, and/or create a scheduled refresh ‎ --- ### Make a download button for SharePoint Library files Forfatter: Alexander Henkel Dato: 2024-09-09 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-09-sharepoint-download-button/ Learn how to create a button with JSON that downloads a file from a SharePoint Library The use case We can take advantage of the always open files of this type, when a specific file is downloaded Step 1 Insert below JSON code to the column formatting to create a button that will download the file from a SharePoint library { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px", "display": "=if([$File_x0020_Type] != '', 'flex', 'none')" }, "children": [ { "elmType": "a", "attributes": { "href": "= @currentWeb + '/_layouts/15/download.aspx?UniqueId=' + [$UniqueId]", "target": "_self", "class": "ms-bgColor-themePrimary ms-fontWeight-bold ms-bgColor-themeDarker--hover" }, "style": { "height": "30px", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Download", "class": "ms-fontSize-16 ms-fontWeight-regular", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "8px", "margin-left": "10px" } }, { "elmType": "span", "style": { "margin-right": "10px" }, "txtContent": "Download" } ] } ] } When the file has been downloaded right click on the file and select always open files of this type Note that there is a potential risk, since your browser will automatically open all files downloaded of this specific file Automatically download and open mail files. We can change the code a little, so that we only show the button for specific file types. In this case we will only show the button when the filetype is msg When we click the button the file is then downloaded locally, and automatically opened up in Outlook { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px", "display": "=if([$File_x0020_Type] == 'msg', 'flex', 'none')" }, "children": [ { "elmType": "a", "attributes": { "href": "= @currentWeb + '/_layouts/15/download.aspx?UniqueId=' + [$UniqueId]", "target": "_self", "class": "ms-bgColor-themePrimary ms-fontWeight-bold ms-bgColor-themeDarker--hover" }, "style": { "height": "30px", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Mail", "class": "ms-fontSize-16 ms-fontWeight-regular", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "8px", "margin-left": "10px" } }, { "elmType": "span", "style": { "margin-right": "10px" }, "txtContent": "Open in Outlook" } ] } ] } ‎ --- ### Update metadata for locked/checked out/need approval files Forfatter: Alexander Henkel Dato: 2024-08-22 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-08-powerautomate-update-locked-files/ Learn how to update columns for files in libraries that are locked/checked out/need approval without raising the version number The challenge If you are working with an IMS/DMS system, you probably have a library with approval, file that needs to be checked out, and an approval flow. During your approval flow you might want to update some of the metadata, but SharePoint requires your flow to check out the document, change your columns, and then check in the document again. It could also be that a document is open by the user, and your flow will stop, because the document is locked by that user. Solution Instead of using the good old Update file properties we are going to update the file properties using the Send an HTTP request to SharePoint action Step 1 We will start our flow from a custom button, so the first action should be For a selected file Step 2 Insert an Send an HTTP request to SharePoint action with the following options Method POST Uri _api/web/lists/getbytitle('NAMEOFYOURLIBRARY')/items(ID)/validateUpdateListItem() Change NAMEOFYOURLIBRARY and ID to the ID from your trigger Headers accept application/json; odata=verbose Content-Type application/json; odata=verbose Body { "formValues": [ { "FieldName": "Editor", "FieldValue": "[{'Key':'i:0#.f|membership|admin@alexanderhenkel.dk'}]" } ], "bNewDocumentUpdate": true } It is important that you keep the Editor, as it should always be available. You can change i:0#.f|membership|admin@alexanderhenkel.dk if you have the claims available in your flow. bNewDocumentUpdate must be true, to update your metadata without incrementing the version number. Your HTTP request should now look like this Step 3 Our flow does not update any columns yet. Let’s begin with a simple update. I have a text field with the internal name Comment let’s update that field with a text. { "formValues": [ { "FieldName": "Editor", "FieldValue": "[{'Key':'i:0#.f|membership|admin@alexanderhenkel.dk'}]" }, { "FieldName": "Comment", "FieldValue": "We successfully updated the metadata" } ], "bNewDocumentUpdate": true } Now try to run your flow (if you don’t know how to trigger a flow from a custom button, you can see how to in this blog post) If we check the version history, you will se that the file metadata has been updated, without incrementing the version number. Try to create a Word file, save the file, but don’t close it and run the flow. Your column should still be updated, even though the file is “locked” Let’s update some other columns. Hyberlink { "formValues": [ { "FieldName": "Editor", "FieldValue": "[{'Key':'i:0#.f|membership|admin@alexanderhenkel.dk'}]" }, { "FieldName": "HyberlinkField", "FieldValue": "https://alexanderhenkel.dk, alexanderhenkel.dk" } ], "bNewDocumentUpdate": true } Managed metadata (single value) { "formValues": [ { "FieldName": "Editor", "FieldValue": "[{'Key':'i:0#.f|membership|admin@alexanderhenkel.dk'}]" }, { "FieldName": "ManagedMetadataField", "FieldValue": "Managedmetadataname|ManagedmetadataID" } ], "bNewDocumentUpdate": true } Managed metadata (multiple values) To update a multichoice metadata field, you must combine the values with ; Managedmetadataname|ManagedmetadataID;Managedmetadataname2|Managedmetadata2ID If you are getting your values from another field/file, you can Append to array variable and then use the Join action Person field The person field can be updated just like the editor field. If you have a multichoice person field, it must be combined with a , Note: You need to add the Claims value, not the Name/Displayname etc. The below code use the Created by and Edited by so you can copy/paste it directly into your flow for editing. { "FieldName": "PersonField", "FieldValue": "[{'Key':'@{outputs('Get_file_properties')?['body/Author/Claims']}'},{'Key':'@{outputs('Get_file_properties')?['body/Editor/Claims']}'}]" } File name To update the filename we can use the FieldName FileLeafRef { "formValues": [ { "FieldName": "Editor", "FieldValue": "[{'Key':'i:0#.f|membership|admin@alexanderhenkel.dk'}]" }, { "FieldName": "FileLeafRef", "FieldValue": "MyNewFileName.png" } ], "bNewDocumentUpdate": true } Here you might want to get the filetype instead of hardcoding it (replace .png with .expression). You can do that by using the following expression (this is taken from the trigger output) last(split(triggerOutputs()?['body/entity/fileName'], '.')) Approval status Note that you can not update the approval status in the same HTTP request as you update all the other fields with. You need a second HTTP request, after you have updated your fields. Note that editor has been removed { "formValues": [ { "FieldName": "_ModerationStatus", "FieldValue": "1" } ], "bNewDocumentUpdate": true } My Power Automate Here is the entire Power Automate code I build, writing this post { "formValues": [ { "FieldName": "Editor", "FieldValue": "[{'Key':'i:0#.f|membership|@{triggerOutputs()['headers']['x-ms-user-email']}'}]" }, { "FieldName": "Comment", "FieldValue": "We successfully updated the metadata" }, { "FieldName": "HyberlinkField", "FieldValue": "https://alexanderhenkel.dk, alexanderhenkel.dk" }, { "FieldName": "ManagedMetadataField", "FieldValue": "@{outputs('Get_file_properties')?['body/ManagedMetadataField/Value']}" }, { "FieldName": "PersonField", "FieldValue": "[{'Key':'@{outputs('Get_file_properties')?['body/Author/Claims']}'},{'Key':'@{outputs('Get_file_properties')?['body/Editor/Claims']}'}]" }, { "FieldName": "FileLeafRef", "FieldValue": "MyNewFileName.@{last(split(triggerOutputs()?['body/entity/fileName'], '.'))}" } ], "bNewDocumentUpdate": true } This blog post was inspired by robertschouten.com reshmeeauckloo.com ‎ --- ### Disable the folder ID for SharePoint document integration Forfatter: Alexander Henkel Dato: 2024-08-08 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-08-model-driven-app-disable-folder-id/ Learn how to disable the folder id when using the SharePoint document integration in Dynamics The challenge When using the SharePoint integration for documents in Dynamics, the standard behaviour is, that the folders are created with a 32 digit unique id. This makes some kind of sense, since it avoids the possibility that two records with the same name won’t share the same folder However, if the users are navigating or working with the SharePoint site, it won’t look very pretty Solution If you found this post, your have probably already setup your SharePoint integration, otherwise you can follow this guide from Matthew Devaney If you understand the risk of having two records with the exact same name sharing the same folder, we can disable the unique id using the tool Org Settings via. XrmToolBox Step 1 Connect to your environment in XrmToolBox and install the tool Org Settings Step 2 Navigate to CreateSPFoldersUsingNameandGuid and select it You can find the documentation from Microsoft here including all the other options available (this is a very powerful tool, fool around at your own risk!) CreateSPFoldersUsingNameandGuid Default: True - creates SharePoint folders using the format of {Name}+{GUID}. False: Creates SharePoint folders using just the name. Step 3 Change the value to false and click the Commit to [environment name] button. The change is only valid for new folders, but now you won’t have the id added to the folder name any longer If you don’t prefer this XrmToolBox solution, you can also change the setting by installing a solution by following this guide (this is the tool that Org Settings for XrmToolBox is based on) Thanks to Luise Freese for making me aware of the possibility to disable the id, through this blog post 🚀 ‎ --- ### Calculate age, anniversary and milestone birthday in Power BI Forfatter: Alexander Henkel Dato: 2024-08-02 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-08-powerbi-date-table-anniversary-milestone/ Learn how to calculate age, anniversaries and milestone birthdays based on date of birth and date of employment The challenge For an HR report we only know the day of birth and the hire date for the employee, but we would like to show the upcoming milestone birthdays and anniversaries. We need to calculate their age, and create filters to show the upcoming birthdays/anniversaries 1 year in advance The solution If you don’t already have a date table, first create one by following this guide Data For this example we will create a simple SharePoint list with Name of employee Day of birth Employment date My list looks like this Age When you have added your SharePoint list to Power BI, remove all other columns, than the 3 above, and change the two date fields to date Your query should now look like this Step 1 Select your day of birth column, and select date -> Age Step 2 Select your new column and click duration -> Total years Step 3 Right click your column and select Transform -> Round -> Round down You can now remove the Age column that we used for the duration calculation, so that our query now look like this Seniority To calculate how many years an employee has been working for the company you can follow the exact same steps as above just with the employment date column. Next birthday and seniority date To show the user of the report when the employee has their next anniversary or milestone birthday add a new custom column Next birthday Formula Date.AddYears([Dayofbirth], [Age] + 1) Next seniority date Formula Date.AddYears([Employeedate], [Seniority] + 1) Don’t forget to change the type of the columns to date Your query should now look like this, and we are ready to start building our report The report (and some DAX) Before you continue, don’t forget to connect your date table to your employees table Step 1 Create a new table visual and insert Name of employee Day of birth Age Employee date Seniority If you want to format the date as in my table above, simply change the format of the date columns to dd-MM-yyyy Step 2 The table will show you all the employees, so now we need add measures that will filter the table for us. Create a new measure with the following code Anniversaries = SUMX( FILTER( Anniversary, Anniversary[Seniority] IN {19, 29, 39, 49, 59, 69, 79, 89, 99} ), Anniversary[Seniority] + 1 ) And another measure with the following code Milestone birthday = SUMX( FILTER( Anniversary, Anniversary[Age] IN {19, 29, 39, 49, 59, 69, 79, 89, 99} ), Anniversary[Age] + 1 ) This code will show us all employees who has a milestone within the next year (with a 10 year interval) but will return the visual with 1 “year” added Step 3 Now we could just add the next birthday and next anniversary date, but that will show the date in all rows To avoid that we will create two new measures, that returns a blank value if their corresponding columns has no value Anniversary date formatted = IF( Anniversary[Anniversaries] = BLANK(), BLANK(), SELECTEDVALUE(Anniversary[SeniorityNextDate]) ) Milestone birthday formatted = IF( Anniversary[Milestone birthday] = BLANK(), BLANK(), SELECTEDVALUE(Anniversary[dayofbirthNext]) ) We now have a table that will show us the upcoming anniversaries and milestone birthdays within the next year 🎂 ‎ --- ### Create a responsive form/app in Power Apps Forfatter: Alexander Henkel Dato: 2024-07-08 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-07-powerapps-responsive-app-form/ Learn how to create a responsive app/form in Power Apps What will we build? In this blog post we will create a responsive app, focusing on the form component The app/form will be build to fit on any screen size or orientation Solution Step 1 Go to make.powerapps.com and create a new blank canvas app (select phone size) Step 2 Go to settings and turn off Scale to fit. Lock aspect ratio should automatically be turned off as well Step 3 In App insert following code MinScreenWidth = Max(App.Width, If(App.Width < App.Height, App.DesignWidth, App.DesignHeight)) MinScreenHeight = Max(App.Height, If(App.Width < App.Height, App.DesignHeight, App.DesignWidth)) Rename Screen1 to startScreen and change the following properties for your screen Width = Max(App.Width, If(App.Width < App.Height, App.DesignWidth, App.DesignHeight)) Height = Max(App.Height, If(App.Width < App.Height, App.DesignHeight, App.DesignWidth)) Fill = RGBA(76, 88, 58, 0.5) These steps are very important for your app to be responsive, and must be included in all screens Step 4 In your startScreen insert a vertical container, and change following properties X = 0 Y = 0 Width = Parent.Width Height = Parent.Height LayoutAlignItems = LayoutAlignItems.Stretch LayoutJustifyContent = LayoutJustifyContent.Center Step 5 Inside your container insert a new Form, you can use the classic form, or the modern. We will continue with modern Go to settings -> updates -> new -> modern controls and themes, to turn it on Use following properties X = 0 Y = 0 Width = Parent.Width Height = Parent.Height Your form should now look something like this Try to run your app. Not very responsive yet. If you run the form on the mobile, the input fields are also very small. Select all your datacards (you can select them in the tree view, you just need to hold CTRL and select minimum 3 times with the mouse) Change the following property Width = Parent.Width Try to run your app again. Now we are getting some better responsiveness Step 6 Go back to app and select OnStart and insert following code Set(vSmallDatacardHeight, 150); Set(vSmallDatacardvalueHeight, vSmallDatacardHeight - 50); Set(vFontSize, 35); Set(vTitleHeight, vFontSize * 2); Right click on App and select Run OnStart (this must be done each time your change any of the properties in OnStart) Step 7 Select all your datacards again and change the following property Height = vSmallDatacardHeight Step 8 Select your titles and change the following properties (here you can only change one at the time) Height = vTitleHeight Size = vFontSize Step 9 Select your input field and change the following properties (again only one at the time can be changed) Height = vSmallDatacardvalueHeight FontSize = vFontSize Step 10 We need a submit form button Select your form and click on Field -> Edit fields Select the 3 dots and click Add a custom card Give your new card the following property Width = Parent.Width Height = vSmallDatacardHeight + vTitleHeight Insert a button and give it the following properties (use a classic button) Fill = RGBA(76, 88, 58, 1) BorderThickness = 0 Width = Parent.Width - 48 Height = vSmallDatacardvalueHeight X = 24 Y = DataCardKey1.Y + DataCardKey1.Height + 4 (DataCardKey1 = the name of your DataCardKey) HoverFill = ColorFade(RGBA(77, 88, 57, 1), -20%) HoverColor = RGBA(255, 255, 255, 1) Text = "Submit form" OnSelect = If(Form1.Valid, Form1.Updates); Notify( "Your form has been submitted", NotificationType.Success, 5000) Try to run your app in various sizes (and publish it and test it on your phone/tablet) So far so good, but the width of the inputs are taking the entire screen. It looks OK on a phone, but not so nice on bigger screens. Let’s add some padding Step 11 Go back to the OnStart property in App and add the following code Set(vPaddingSmall, 0); Set(vPaddingMedium, 0.1); Set(vPaddingLarge, 0.15); Set(vPaddingExtraLarge, 0.20); The numbers represent the percentage of padding we would like (on each site), so you can change them to your liking. Don’t forget to Run OnStart Step 12 Select your container and insert following code PaddingLeft = Parent.Width * Switch( Parent.Size, ScreenSize.Small, vPaddingSmall, ScreenSize.Medium, vPaddingMedium, ScreenSize.Large, vPaddingLarge, ScreenSize.ExtraLarge, vPaddingExtraLarge, 0 ) PaddingRight = Self.PaddingLeft We now have a form that looks good on various screens Here is the app on an iPhone in portrait mode Here is the app on the same phone, but in landscape mode Here is the app on an iPad in portrait mode Here is the app on the same tablet, but in landscape mode Download example app You can download my example app here You need to add your own data source! You can open the app by creating a new blank Canvas App and find the open menu here ‎ --- ### Trigger a Power Automate Flow with modern ribbon, with a custom button in Dynamics 365 (not Ribbon Workbench) Forfatter: Alexander Henkel Dato: 2024-07-04 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-07-model-driven-app-start-power-automate-flow-modern/ Learn how to start a Power Automate flow in a model driven app with the modern ribbon The challenge In an earlier post I explained how you could start a Power Automate Flow with a custom button using Ribbon Workbench This requires you to install a new solution - but we do also have the possibility to do the same with the modern ribbon One of the things I missed the most about the Ribbon Workbench was the possibility to refresh (or save) the form - the below code can do that :) Solution Step 1 Go to your app and select Edit command bar -> Edit Choose Main form and select Edit Choose New -> Command Give your new button a name and an icon We will come back to the button later Step 2 Power Automate Flow Go to make.powerautomate.com and create a new Automated cloud flow As a trigger you should use When a HTTP request is received Notice that here is a security risk. Since anyone who (potentially) has this URL could start your flow. In Request Body JSON schema insert the following code { "type": "object", "properties": { "id": { "type": "string" } } } This code will get the ID of the record. We can use this ID to receive the remaining information about the record by using Get a row by ID action Next insert a new action called Response and insert 200 in Status code Now you could add an action that updates your current record. Use the action Update a row and use the id from your trigger (When a HTTP request is received) Webhook URL You need to save your Power Automate flow before you can get the HTTP POST URL. Once it has been saved, copy the URL from your trigger action, and insert it into the code below The code The below code is written so that you have some flexibility without having to understand code. In all of the lines down to #132 you can change the value of the variables. You can begin by just copy-pasting the code and save it as a .js file (don’t forget to insert your http post url in the first variable) // Power Automate HTTP POST URL from the trigger "When an HTTP request is received" // Insert the link between the ` and ` // This field is required to change const flowURL = `INSERT YOUR HTTP POST URL FROM POWER AUTOMATE HERE`; // Popup confirmation before running the flow const confirmationOptionsShow = true; // use false if no confirmation is needed const confirmationOptionsText = "Do you want to start the flow?"; // Confirmation text const confirmationOptionsTitle = "Start flow"; // Confirmation header const confirmationOptionsHeight = 200; // Height const confirmationOptionsWidth = 150; // Width const confirmationOptionsOKLabel = "Ok"; // Confirm button text const confirmationOptionsCancelLabel = "Cancel"; // Cancel button text // Top bar notification while flow is running const flowRunningOptionsShow = true; // use false if no message is needed const flowRunningOptionsText = "Flow is running please wait..."; // Notification text // Top bar notification when the flow has finished successfully const flowRunningOptionsSuccessShow = true; // use false if no message is needed const flowRunningOptionsSuccess = "The flow has finished successfully"; // Notification text // Popup confirmation when the flow has finished successfully const successOptionsShow = false; // use true if a popup message should be shown const successOptionsText = "The flow has finished successfully"; // Confirmation text const successOptionsTitle = "Flow was successful"; // Confirmation header const successOptionsHeight = 200; // Height const successOptionsWidth = 150; // Width const successOptionsConfirmationLabel = "Ok"; // Confirm button text // Popup confirmation if the flow has finished with an error const errorOptionsShow = false; // use true if a popup message should be shown const errorOptionsText = "The flow has failed"; // Confirmation text const errorOptionsTitle = "Error"; // Confirmation header const errorOptionsHeight = 200; // Height const errorOptionsWidth = 150; // Width const errorOptionsConfirmationLabel = "Ok"; // Confirm button text // Top bar notification if the flow has finished with an error const flowRunningOptionsErrorShow = true; // use false if no message is needed const flowRunningOptionsError = "The flow has finished with an error"; // Confirmation text // Clear top bar after X seconds const removeNotificationsOptions = 10000; // Milliseconds // Refresh the form when the flow has finished const refreshFormOptions = true; // use false if the form should not be refreshed // Save the record before the flow is running const saveFormBeforeRunningOptions = true; // use false if the record should not be saved (higher risk of failure) // NO NEED TO CHANGE BELOW CODE // // Get record ID const getEntityValues = async (FormContext) => { const entityId = FormContext.data.entity.getId().replace("{", "").replace("}", ""); return entityId; }; // Send the entityId to Power Automate const postEntityId = async (recordId) => { const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: recordId }), redirect: 'follow' }; try { const response = await fetch(flowURL, requestOptions); return response.ok; } catch (error) { console.error('Failed to post entity ID:', error); return false; } }; // Main function to handle the button click async function main(FormContext) { try { const entityId = await getEntityValues(FormContext); // Show confirmation dialog if (confirmationOptionsShow) { const confirmStrings = { text: confirmationOptionsText, title: confirmationOptionsTitle, confirmButtonLabel: confirmationOptionsOKLabel, cancelButtonLabel: confirmationOptionsCancelLabel }; const confirmOptions = { height: confirmationOptionsHeight, width: confirmationOptionsWidth }; const confirmed = await new Promise((resolve) => { Xrm.Navigation.openConfirmDialog(confirmStrings, confirmOptions).then( function (success) { resolve(success.confirmed); } ); }); if (!confirmed) { return; } } // Save the form before running the flow if (saveFormBeforeRunningOptions) { await FormContext.data.entity.save(); } // Show flow is running notification if (flowRunningOptionsShow) { FormContext.ui.setFormNotification(flowRunningOptionsText, "INFO", entityId); } const flowCompleted = await postEntityId(entityId); if (flowCompleted) { // Refresh the form if (refreshFormOptions) { FormContext.data.refresh(false); } // Success message to user if (flowRunningOptionsSuccessShow) { FormContext.ui.setFormNotification(flowRunningOptionsSuccess, "INFO", entityId); } // Success popup to user if (successOptionsShow) { const confirmStringsSuccess = { text: successOptionsText, title: successOptionsTitle, confirmButtonLabel: successOptionsConfirmationLabel }; const confirmOptionsSuccess = { height: successOptionsHeight, width: successOptionsWidth }; Xrm.Navigation.openAlertDialog(confirmStringsSuccess, confirmOptionsSuccess); } } else { // Error notification to user if (flowRunningOptionsErrorShow) { FormContext.ui.setFormNotification(flowRunningOptionsError, "ERROR", entityId); } // Error popup to user if (errorOptionsShow) { const confirmStringsError = { text: errorOptionsText, title: errorOptionsTitle, confirmButtonLabel: errorOptionsConfirmationLabel }; const confirmOptionsError = { height: errorOptionsHeight, width: errorOptionsWidth }; Xrm.Navigation.openAlertDialog(confirmStringsError, confirmOptionsError); } } // Clear notifications if (flowRunningOptionsErrorShow || flowRunningOptionsSuccessShow || flowRunningOptionsShow) { setTimeout(function () { FormContext.ui.clearFormNotification(entityId); }, removeNotificationsOptions); } } catch (error) { console.log(`Main function failed: ${error}`); } finally { console.log("Main function stopped"); } } Step 3 To upload your new JavaScript file go back to your button and change the action to Run JavaScript Select Add library Select New web resource Upload your file, and give it a name, and click Save and publish In Function name write main and in Parameter 1 select PrimaryControl Click Save and Publish and your new button should be ready. If you want to edit some of the variables/options in the flow, simply re-upload the file again by clicking the pen Click choose file and save and publish - go back to your model driven app and refresh the page, and the new script is ready to run Reference Here are a few snapshots of the flow running Confirmation Notification Success message Error message ‎ --- ### Make an API request with a custom button (Modern Ribbon) Forfatter: Alexander Henkel Dato: 2024-07-01 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-07-model-driven-app-api-request/ Learn how to update an account using VAT data through an API call (Power Automate) and JavaScript, using the Modern Ribbon (not Ribbon Workbench) The challenge When creating a new account in Dynamics 365, it can be time consuming to enter the correct account data, and there is a risk of mistakes. In many countries, the company data is publicly available, and the information can usually be accessed through an API call Let’s make a custom button that starts a Power Automate flow that can get this data, and return it to Dynamics Solution In this blog post I will be using the Danish equivalent to VAT (CVR) I found two good Danish providers - I will continue with the first one (free up to 50 API calls pr. day) cvrapi.dk virkdata.dk Step 1 Create a custom button We can search the API using either the company name or the VAT number. We will use the VAT number in this exercise. If you don’t have a field for VAT, create one and insert it into your form. Go to your app, and add a new button to your main form. I haved named my button update account data We will return to the button again. Step 2 Create a Power Automate Flow Go to make.powerautomate.com and create a new Automated Cloud Flow (you might also consider setting this flow up as a Logic App in Azure) Step 3 Your first action (trigger action) is when a HTTP request is received In the Request Body JSON Schema insert the following code { "type": "object", "properties": { "id": { "type": "string" } } } Change who can trigger the flow to anyone Warning anybody who has the HTTP POST URL could potentially trigger the flow Step 4 Insert a get a row by ID action and insert the id from the trigger output. Make sure that you are also limiting the output in select columns to the internal name of your VAT-number field (we want this flow to run as fast as possible) Step 5 Insert a new action called HTTP The documentation for CVRAPI is in Danish, but it is simple - the request for a search is https://cvrapi.dk/api?search= & account name or VAT number. The documentation also explains that a country code is always required, so we must also insert &country=dk Here is two API examples https://cvrapi.dk/api?search=Microsoft&country=dk https://cvrapi.dk/api?search=13612870&country=dk virkdata.dk requires an API Key. To use an API key, change authentication to raw and insert the API key in value (this is not needed for cvrapi) Now our HTTP request will look like this You can check out the data that the API is returning by following this link (this will also give you the internal names that we are going to use in the next step) cvrapi.dk/api?search=13612870&country=dk Step 6 Next we want to update our account, so the next action should be update a row Row ID should be id from our trigger We will update Account name, ZIP code and Main Phone, you can choose other fields from the API if you like (if you know parse from JSON, you can also do that instead) Account name outputs('HTTP')['body']?['name'] ZIP code outputs('HTTP')['body']?['zipcode'] Main Phone outputs('HTTP')['body']?['phone'] Step 7 Our last action is going to be response Make sure the status code is 200 Step 8 Save your flow. If you go to the top of the flow you will see that a link is now available in the HTTP POST URL, we are going to use this link later If you want to improve your search result you could change the flow so that if a VAT number is available we will use this as a search query - if not, then we use the company name The code Now we are going to use some code. I left some comments in the code, so you can follow along what is happening in the console Explanation of the code The code is triggered by the main function, which is then getting the context of the form (FormContext). We are then asking the code to return the ID of the record. The record is returned with a { and a }, so we are “cleaning” up the ID by removing the curly brackets with .replace When the user is clicking the new button, we are asking if the user wants to continue with Xrm.Navigation.openConfirmDialog If user selects no, we are cancelling the script. If the user select yes, we are then posting the EntityID that we got using FormContext.data.entity.getId() to the Power Automate flow with the PostRecordID function We are then showing a FormNotification to tell the user that a flow is running. If the HTTP request fails, we are telling the user that we could not find the company (the documentation can send other errors, but we don’t handle them in this exercise) If the HTTP request succeed, Power Automate will update the record for us, and we will refresh the form with FormContext.data.refresh(false) and then update the FormNotification Last, we will make sure to clear the form notification after 10 seconds with the setTimeout function Copy below code and save it as a .js file - remember to insert your own HTTP POST URL in the code I have named my JavaScript file updateCompanyData.js // Get values from the form context const getEntityValues = async (FormContext) => { console.log("Called"); const entityId = FormContext.data.entity.getId().replace("{", "").replace("}", ""); console.log(entityId); return entityId; }; // Send the entityId to Power Automate const postEntityId = async (recordId) => { const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: recordId }), redirect: 'follow' }; const url = `INSERT HTTP POST URL HERE`; try { const response = await fetch(url, requestOptions); if (response.ok) { console.log("Flow completed successfully."); return true; } else { console.log("Flow completed with error:", response.status, response.statusText); return false; } } catch (error) { console.log('Error executing flow:', error); return false; } }; // Main function to handle the button click async function main(FormContext) { try { console.log("Started"); const entityId = await getEntityValues(FormContext); // Show confirmation dialog const confirmStrings = { text: "Do you want to update the account data?", title: "Update account data" }; const confirmOptions = { height: 200, width: 450 }; const confirmed = await new Promise((resolve) => { Xrm.Navigation.openConfirmDialog(confirmStrings, confirmOptions).then( function (success) { resolve(success.confirmed); } ); }); if (!confirmed) { console.log("User canceled the action."); return; } // Save the form before running the flow await FormContext.data.entity.save(); FormContext.ui.setFormNotification("Getting company data, please wait...", "INFO", entityId) const flowCompleted = await postEntityId(entityId); if (flowCompleted) { console.log("Flow execution successful, refreshing form..."); // Refresh the form FormContext.data.refresh(false); console.log("Form refreshed."); // Success message to user FormContext.ui.setFormNotification("Company data has been updated successfully", "INFO", entityId) } else { console.log("Flow execution failed or returned non-200 status."); FormContext.ui.setFormNotification("Error: Could not find any company data. Is the VAT number correct?", "ERROR", entityId) } //Clear the success message setTimeout(function() { FormContext.ui.clearFormNotification(entityId); }, 10000); console.log("Main function completed"); } catch (error) { console.log(`Main function failed: ${error}`); } finally { console.log("Main function stopped"); } } Step 9 Let’s go back to our button. In the command bar click Add library and then select New web resource Upload your JavaScript file and give it a name. I have called mine updateCompanyData Step 10 After you have uploaded your new web resource, search for the resource, select it and click add Step 10 In the command bar menu insert main in function name and under Parameter 1 select PrimaryControl Step 11 Visibility We don’t want the button to be available if the form is new (the user has not clicked save), or if the VAT field is empty. In the command bar menu under visibility, change the value to Show on condition from formula and insert the following code If(FormMode = FormMode.New || IsBlank(Self.Selected.Item.VAT), false, true) Change .VAT to the display name of your column, if it is not VAT The only thing that is left to do is to click Save and Publish Running the code After the code has been uploaded, we can now make an update of the company data with a click of a button If the form is new we won’t show the button just yet We won’t show the button either if the VAT field is empty Here the user has inserted the VAT number, and the button update account data is visible - dare we press it? We ask the user if they want to update the company data We are now telling the user that the data is being collected (usually takes 1-2 seconds) Here we have updated the data, and is telling the user it has been updated successfully Here we are telling the user that a company could not be found (since the VAT is wrong) I am sure you can find equivalent API services for VAT in your country. If not, I recommend you try openweathermap.org so that you can always update the current weather for the company you are going to visit next And finally a little disclaimer - I am a low-code dude, the code above might not be 100% correct - this blog is just me trying to share what I am learning :) ‎ --- ### Change choice column with modern ribbon custom button in Dynamics 365 Forfatter: Alexander Henkel Dato: 2024-06-28 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-06-model-driven-app-modern-ribbon-option/ Learn how to create a custom button in Dynamics 365 and change a choice/option set value The goal In this exercise we will add a custom button to our model driven app, for the user to change the value of an option set/choice column. How to Step 1 In the contacts table create a new choice field and add the following options: Has been contacted Has not been contacted Add the field to your form Step 2 Edit your app, and in the navigation panel click the 3 dots on Contacts view -> Edit command bar -> Edit In the next window select Main Form To add a new button to the ribbon click New in the top left corner and choose Command Select your new button and in the command menu bar select a name and an icon Step 3 Add the following code to the OnSelect property of the button If( Self.Selected.Item.'Contact Status' = 'Contact Status (Contacts)'.'Has not been contacted', Patch( Contacts, Self.Selected.Item, {'Contact Status': 'Contact Status (Contacts)'.'Has been contacted'}), Patch( Contacts, Self.Selected.Item, {'Contact Status': 'Contact Status (Contacts)'.'Has not been contacted'} ) ); Notify("Contact Status changed to: " & Lower(Self.Selected.Item.'Contact Status'), NotificationType.Success,5000); Click save and publish Step 4 Our new button is now ready in our model driven app, and can change the value of our record ‎ --- ### Change permissions and add an Office 365 Group in Power Automate Forfatter: Alexander Henkel Dato: 2024-06-18 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-06-powerautomate-permissions-office-365-group/ Learn how to change the permission levels for a document set and add an Office 365 Group In this case we will learn how to change the permission levels for a document set when the user chooses a confidentiality level “strictly confidential” You can change the permission levels using some default actions, but you can’t make very complex changes, and for some reason it does not support ex. document sets Here is the Microsoft article for the standard actions Let’s get started As a trigger we will use “When a file is created (properties only) Next we will check if our field value of Confidentiliaty is equal to Strictly Confidential The permission level in SharePoint can be very complex, but what we want to do is Stop inheriting roles Remove all permissions for the file Add permissions again, and the permission level Remove permissions First we will stop inheritance by using the action Send an HTTP request to SharePoint _api/web/lists/getByTitle('LIBRARYNAME')/items(FILEID)/breakroleinheritance(true) In SharePoint, right click on the File -> Manage Permissions, select the 3 dots in the top right corner and select advanced settings Now you can see how the permission levels it no longer inherited from the top level of the site, and instead of looking like this 👇 It now looks like this 👇 Now we will get all of the permission that was originally inherited to the file, and delete them one by one. Add another HTTP request _api/web/lists/getByTitle('LIBRARYNAME')/items(FILEID)/roleassignments Now, run your flow, because we need the “body” output from above action. When the flow has run, copy and paste everything from the body Insert a Parse JSON action, and select Generate from sample, paste in what we just copied and click done Add another HTTP request action api/web/lists/getByTitle('Working library')/items(FILEID)/roleassignments(PrincipalID) The principalID will be available from your dynamic content. When you insert the PrincipalID, Power Automate will automatically insert an Apply to each, which is fine, because it is now running trough all of the permissions we collected earlier. Add below to the Headers to ask for the permissions to be deleted (see the picture below) X-HTTP-Method DELETE Now that we have removed all the permissions, we can now add either a SharePoint Group or an Office 365 Group (note that we need to use a different method, depending on what type of group you are working with) Remember that when you are removing all of the permission, you are also removing the account that is running the flow. If the flow needs to make any changes to the file or the metadata, remember to add permissions to that account. Add a SharePoint Group When we are adding the groups (or people), we need to tell what type of permission is needed (ex. “Full control”, “Read”, “Edit”, “Contribute”, or if you have made your own permission level type) Each permission has a unique ID, and we can get that with an HTTP request. Here we are getting the ID of the “Full control” permission. We will use this information later. _api/web/roleDefinitions/getByName('Full control') Next we want to get the ID of the SharePoint group. I have one that is called “DMS Owners”. We will also use this information later. _api/web/siteGroups/getByName('DMS Owners') If you run your flow, and look in the output of the two requests we just made, you can see that it begins with “d” and if you scroll down a bit you will also find ID. Add another HTTP request api/lists/getByTitle('LIBRARYNAME')/items(FILEID)/roleAssignments/addRoleAssignment(PrincipalId=GROUPID,roleDefId=PERMISSION) Now we are using the output from the previous two actions that we added. Outputs -> “d” -> “ID” Add below as an expression in your flow (see the picture below) GROUPID = body('Send_an_HTTP_request_-_Get_DMS_owners_groupID')?['d']?['Id'] PERMISSION = body('Send_an_HTTP_request_-_Get_DMS_Full_Control_ID)?['d']?['Id'] Try to run your flow, and if everything works well, all of your permissions are removed, and your SharePoint Group has now been added with the “Full control” permissions. Adding an Office 365 Group On your SharePoint site is, what I will call, a “hidden” list of all the users or groups that has ever associated with the site. To access the list to go this URL 👇 https://TENANT/sites/YOURSITENAME/_layouts/15/people.aspx?MembershipGroupId=0 If you hover your mouse over the name, you can see in the URL (in your browsers left corner) that they all end with ID=xx What we want to do now is to get that ID, and we do that by adding another HTTP request _api/web/ensureuser content-type application/json;odata=verbose accept application/json;odata=verbose { 'logonName': 'groupname' } In my example below I have used an expression since the group name is not necessarily always the same, but if the group name is not dynamic, you can hardcode it instead like ’logonName’: ‘groupname’ Now that we have the ID of the group, we can add the group permission to the file. We will use the same permission levels from earlier (“Full control”) api/lists/getByTitle('LIBRARYNAME')/items(FILEID)/roleAssignments/addRoleAssignment(PrincipalId=GROUPID,roleDefId=PERMISSION) Add below as an expression in your flow (see the picture below) GROUPID = outputs('Ensure_Group')?['body']['d']['Id'] PERMISSION = body('Send_an_HTTP_request_-_Get_DMS_Full_Control_ID)?['d']?['Id'] Now we have successfully added an Office 365 Group permission to the file with a “Full control” permission If you want to add other groups, or use other permission levels, you need to add additional HTTP requests, for each individual group or permission level you want to retrieve. ‎ ‎ --- ### Combine two columns and patch a lookup field in Power Apps Forfatter: Alexander Henkel Dato: 2024-06-14 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-06-powerapps-combine-two-columns/ Learn how to combine two columns in a lookup field, in the Power Apps integrated form (SharePoint) The Challenge Working with the SharePoint Integrated Power App Form can be both very frustrating, but can also bring a lot of power to your lists, if you are (like me) a low-code person. I have build very complex forms, and I think with the new modern components, it is now not looking like it was build in 1999. I had a case where I had a list of projects, with a lookup field, to a customer source list. The customer source list had one column with the customer name, and in the other column a customer number. In my project list I wanted to combine the two columns, so that the user could search for both the customer name and customer number in using the same field The solution Step 1 Create a new list named Customers and create two new columns ‏ CustomerName (Text) CustomerNumber (Text/Number) ‏ Step 2 Create another list named MyProject and create one new column ‏ Customer (Lookup) ‏ Make your new column lookup into your Customers list and select CustomerName as the column. Under More options bring over CustomerNumber Step 3 Select the 3 dots in the MyProjects list and go to -> Power Apps -> Customize Forms In Power Apps, select your DataCardValue with the customer and go to the property Items and insert following code AddColumns( Customers, "CustomerNameAndNumber", CustomerNumber & " - " & CustomerName ) Now you should be able to see how the customer number and customer name is combined in your datacard. Try to upload your form and add a new item to your project list and see how… Nothing happens!? This is because a lookup field is a complex type of field, and to be updated it requires the ID and the Value (CustomerName) If you select your entire DataCard there is an update property. Right now it is Parent.Default, but since we combined two columns (actually added a new column), we need to give the ID and Value ourself. Change the code in the update property to the following { Value:DataCardValue2.Selected.CustomerName, Id: LookUp( Customers, CustomerNumber = DataCardValue2.Selected.CustomerNumber).ID } You might need to change DataCardValue2 to the name of your DataCard First we are taking the value from our DataCardValue (CustomerName) Since we don’t have the ID we are using LookUp to find it from our customer list. We are using the CustomerNumber as the lookup value in our customer list since it should be unique (there is a risk that the same customer could have several accounts) Try to upload your form to SharePoint again. Now you can see that the customer is added to an item. But if you open the form (edit or view) you will see that the field is blank. This is because we have to build the “Add columns” in the property DefaultSelectedItems AddColumns( Customers, "CustomerNameAndNumber", ThisItem.'Customer: CustomerNumber'.Value & " - " & ThisItem.Customer.Value ).CustomerNameAndNumber) Again, upload your form and see how the field is now both saved to your list, but also shown with the correct data when in edit/view mode. Remove the - from the form. We have used a - to combine the two columns, so when you create a new item you will see that the - is just staring at you. To avoid this, select the SharePointIntegration item from the tree view and to go advanced. In the OnNew property create a new variable: Set(varFormMode, “New”); In fact, let’s add the form mode to each action, this will be helpful when we are working with more complex forms Now we can update the DefaultSelectedItems to show “nothing” if the FormMode = New If(varFormMode <> "New", AddColumns( Customers, "CustomerNameAndNumber", ThisItem.'Customer: CustomerNumber'.Value & " - " & ThisItem.Customer.Value ).CustomerNameAndNumber) We added an if statement saying if varFormMode it not equal to “New” then there should be no default selected item, otherwise it should be the value of the item we selected There you go, you now know how to combine two columns in a combo box but also how to update a lookup field --- ### Avoid loops when working with multi choice columns for emails and approvals Forfatter: Alexander Henkel Dato: 2024-06-09 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-06-powerautomate-avoid-loop-with-multichoice-column/ Learn how to extract values from a multi choice column in SharePoint, and avoid loops in Power Automate The challenge If you want to sent some details from a multi choice column in an email or an approval flow, Power Automate will create a loop since the value is an array Here is how to avoid that Step 1 Initialize a new Array variable (I have called mine MultiChoiceColumn) Step 2 Create an Append to array variable action and in Value insert your ColumnName Value Power Automate will automatically create a loop. This is fine, keep the loop Step 3 Outside the loop create a Compose action and in Inputs insert the Array value you created in the first step Step 4 Create a new Join action. From should be Outputs from your Compose action and in Join with insert a comma and a space Step 5 In your e-mail you can now insert the output from your Join action Your e-mail should now look like this If you don’t want your choices to be in a single string, your can replace the comma and space with
to create a line break Your e-mail should now look like this Approval flow We can’t use
to create line breaks in an approval flow, it would look like this Here is how to create a line break for approval flows Step 1 Initialize a new string variable and in the value press enter once (it looks blank, but the string now holds an enter value) Step 2 Insert the variable in the Join with Step 3 Insert the Outputs from your join action in the Details option Your approval flow should now look like this Here is the entire flow If you have several choice columns, it would be best practice to use scopes (it would also be best practice to use a scope if you have only 1 column) ‎ --- ### May in M365 Forfatter: Dan Toft Dato: 2024-05-31 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-05-m365/ May is always busy time in the land of Microsoft, this year was no exception! - We started off with the M365 Community conference and ended the month with Microsoft Build, and in between there were a lot of cool announcements, smaller community conferences, including European Collaboration Summit, where I got to deliver my first session ever, talking about the value of community, but that’s enough fluff, let’s dig in! - here are some of the highlights from the month of may in M365! SharePoint Shortcuts can be moved A while back Microsoft introduced the concept of “add shortcut to OneDrive” in SharePoint, I did an entire blog post on that feature - the biggest problem was that these shortcuts kept piling up in the root of the users OneDrive, and there was no way to move them into folders (except for using code), well that’s changing now, you can now move these shortcuts into folders. Read more Copilot speaks multiple languages As a bilingual user, Copilot hasn’t been worth a whole lot for the past months, but that however has all changed now! - Copilot now speaks a long list of languages, it being able to finally understand my Danish content is a game changer, and it’s finally creating real world value for me now! Read more Follow meetings in Outlook Have you ever wanted to attend a meeting, but been double booked, or maybe had a meeting in a different time zone, and you just couldn’t make it? Well this new feature in Outlook is for you! - instead of declining the meeting, you can now follow it, this will prompt the attendees to record the meeting, so Copilot can create a summary, and you can watch the meeting later - or just read the recap. Read more Windows 11 teams rename Anyone who’s used windows 11 will know the frustration of having several teams clients, not just classic and new, but also that stupid “personal” client, which is just called teams, seemingly only to confuse users. Well that changes now, the personal client will be renamed to “Teams personal” - so now you can easily distinguish between the clients. Read more Outlook Copilot will be Graph grounded You might not know this yet, but not all Copilots are created equal, for instance Copilot chat in Outlook can’t access your organization data, while Copilot in Teams can, well that’s changing now, Copilot in Outlook will soon be able to access your organization data, and provide you with the same level of insights as Copilot in Teams. Unfortunately it’s still not context aware of what’s on your screen, but it’s a start, I’m glad to see Microsoft starting to focus on aligning the different Copilot experiences to be more consistent. Read more Approvals on any list Approvals are a great feature in Microsoft 365, and a critical part of any modern organization, Microsoft knows this and will soon be expanding the approvals feature, to quickly let you submit any list item for approvals, that will show up in teams of the approver, this is great, enabling end users to quickly get value from the product is awesome! Read more Block unmanaged solutions If you’re using the Power Platform, and properly using environments, you no doubt know the struggle of people deploying unmanaged solutions to production, and suddenly your test, and dev environments are out of sync, and you’re stuck with an app that everyone is scared to update. Well no more, you can now on an environment level block unmanaged solutions! Read more Dataverse restore deleted records As a SharePoint guy, this has always been one of my biggest issues with Dataverse, once you delete something, it’s gone, and you can’t get it back, well that’s changing now, you can now restore deleted records in Dataverse, this is a game changer, and will make it much easier and safer to work with Dataverse. Read more Microsoft Places Public Preview Remember Places? - a product Microsoft announced like two years ago, and then promptly never talked about again? - well it’s now gone into public preview, powered by Teams Premium. Places is a super cool AI powered tool that’ll allow you to see who’s working where, and better schedule meetings whenever it works for everyone. This product seems really really cool, but I’m pretty bummed that it’s only available in Teams Premium, I’m sure it’s going to be a great feature, but I’m not sure it’s worth the price of Teams Premium alone - and with Copilot for Microsoft 365 also being in there with features like recap, I’m worried how adoption will look for places. Read more Domain Isolation retirement SPFx This one is a minor thing but it caused a lot of commotion in the community, Microsoft is retiring the domain isolated web parts in SharePoint Framework. This was a feature where your web parts could have permissions only tied to your specific web part, admittedly with a massive performance impact, but the way SPFx permissions work out of the box, is that if one app has say “mail.send” permission, all other SPFx apps in the same tenant have that permission. While SPFx is only delegated permissions, and not application permissions, it’s still a bit of a security frustrations, and I’m sure this change will cause a lot of headaches for developers. I’m hoping Microsoft will come up with a better solution for this, some time in the future, but for now, it looks pretty … sad. Read more Build Microsofts build event took place this month, and there were a lot of cool announcements, here are some of the highlights, all taken from the Book of News Create Copilots in SharePoint One of the really cool things we saw at build was the ability to quickly create your own custom Copilot in SharePoint, simply select a few documents or folders, and with a click of a button you’ll have a Copliot that’s a specialist in those documents. This Copilot is sharable with your colleagues as well, really really cool, but my governance heart is aching at the thought of Copilot sprawl. Copilot Extensions In Microsoft rebranding news, all extensibility features for Copilot (Graph Connectors, and plugins) will now be called “Copilot extensions” - I wonder what more is to come! Team Copilot This is the first real agent we see in Copilot, here you get an entire extra employee for your team, a Copilot you can invite to join your team, and as a new employee - focusing initially on the following roles: “Meeting facilitator”, Copilot takes real time notes, and aligns the agenda with what has been talked about, and what is being talked about. “Group collaborator”, helps bring focus to important information, and tasks that may have been lost. “Project manager”, Copilot ensures that tasks are created, maintained, and reminded across a project. How cool is that? - AI agents in your team, not taking the place of a project manager, but certainly making their lives a lot easier. Copilot Agents in Copilot studio What exactly this will be, I’m intrigued to see, but it looks like Copilot studio will allow users to create their own Copilot agents, an agent is a Copilot that can act more on it’s own, and not just as a direct helper to the user when prompted to do so. AI Flows Coming to Power Automate, the option to have AI Flows, much like we know it today Power Automate can automatically generate a flow based upon a prompt, but instead of it being generated, and you just tweak it, the idea here is that every time based on the users request a flow is generated that runs, and every time a user asks, well the flow might look different. Flows inside Microsoft Copilot Copilot for Microsoft 365 will get the ability to call flows, as a sort of plugin, that’s cool! - I’m not 100% sure when or how exactly this will be implemented, but from the sound of it it’s a cool feature. SharePoint embeded goes GA Not a whole lot to add here, but SharePoint embeded that was announced in preview at ESPC in december, is now generally available, giving developers the option to use the headless SharePoint in their applications, without the UI of SharePoint. Windows 11 on ARM and Copilot + PC Microsoft announced their first line of ARM laptops, that they claim will perform better than the apple M3 Macbooks, and as someone who has switched to mac for the M series processors, I’m really excited to see where this goes, and if it can live up to the hype. Along with the ARM laptops, Microsoft also announced the concept of Copilot + PC, a feature that using and NPU (Neural Processing Unit) allows you to run Copilot locally on your PC, and not in the cloud, this is a game changer, and I’m really excited to see where this goes, the first feature then announced to take advantage of this was “Windows Recall” a feature that will essentially monitor everything you do on your PC, and allow you to prompt Copilot to recall what you did, or saw! - this is a mega cool feature, and I’m really excited to play with it, but also very woried about the privacy implications. TL;DR Loads and loads of cool stuff coming in the coming months, and I’m really excited to see where it goes, especially when it comes to being able to build your own Copilots in no time! - and the implications of agents in the daily life of a user! --- ### Automatically email new leads from past 24 hours in Dynamics 365 Forfatter: Alexander Henkel Dato: 2024-05-30 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-05-powerautomate-mail-new-leads/ Learn how to email leads from the past 24 hours to the specific lead owner in an HTML table The challenge In this case, new leads are generated automatically in Dynamics 365, from a webpage contact form, and then assigned to different owners. We want to collect the new leads from the past 24 hours and send each individual owner a reminder (in a table format) via email. The challenges we have The createdon time in Dynamics is in UTC (while I live in Denmark). We need to identify owners who have new leads to avoid sending empty tables/emails. We must create a table that includes only the leads for a specific owner. We need to construct the table format for the email. The solutions We will create a Power Automate flow that runs daily at 10:00 AM (local time) that collects all new leads created in the past 24 hours and assign them to their respective owners. This is the flow we are going to build The flow Create a scheduled cloud flow First we will create a new scheduled cloud flow and set it to run at 10:00 AM. We will initialize a new array variable called OwnerID to store all the different owners. Retrieve leads within the past 24 hours To retrieve leads created within the past 24 hours, we will query the leads table, and apply an ODATA filter to the createdon field. The data we get from Dataverse from the column createdon is in UTC, and we want to convert that to our current timezone. Since the flow is running at 10:00 AM we will just get the current UTC() time, convert the timezone from UTC to (in my case) W. Europe Standard Time, and then we will subtract 1 day (addDays -1) Expression 1 formatDateTime(addDays(convertTimeZone(utcNow(), 'UTC', 'W. Europe Standard Time'), -1), 'yyyy-MM-ddTHH:mm:ssK') Next we will use the same code, but without removing 1 day. Expression 2 formatDateTime(convertTimeZone(utcNow(), 'UTC', 'W. Europe Standard Time'), 'yyyy-MM-ddTHH:mm:ssK') The final filter rows will look something like: created on ge ’expression1’ and createdon le ’expression2’ ge = Greater than or equal to le = Less than or equal to Get unique owners Now that we have all the leads within the past 24 hours, we will loop through each of them, to find out who owns the leads. Create an apply to each action and insert value from List rows leads Inside the apply to each add a condition that express: ownerID does not contain Owner (Value) Then in the Yes condition, add an Append to array variable and add the value to the array variable Now we know how many unique owners we have, and who we should send an e-mail. Create a loop for each owner and filter array Create another apply to each (outside the previous loop) and this time select ownerID as the output from the previous step Create a Filter array action and add the following From = Value (from List Rows) Owner (Value) is equal to current item (be careful: You have two current item so make sure you select the one from your second/current loop) Select columns Add a Select action and in the From add Body from our Filter Array action Now we will begin to build our table. In the Map write html in the left site column. For my HTML table, I have taken the following fields subject firstname emailaddress1 The ID so that I can create a link You can get the internal names of the columns you are looking for by looking inside the filter array output (this is just one way to get the column names) Very important You should replace each item within the { } in the below html code with the following expression {item()?['subject']} should be replaced with following expression item()?['subject'] In the last column, Open in CRM, replace YYY with your tenant and ZZZ with your app ID {item()?['subject']} {item()?['firstname']} @{item()?['lastname']} {item()?['emailaddress1']} Open in CRM Select HTML Still working inside your loop add another Select action - Output should be from the previous Select action we just created. Click the little icon with a T and insert the following expression item()?['html'] Building the table Next up, create a new compose action The below code is building the table header, and is implementing all the table rows we made earlier. Insert the following code in the compose action
HERE WE ARE GOING TO INSERT AN EXPRESSION LATER
Subject Name Email Open in CRM
Replace Subject, Name, Email and Open in CRM with your own headlines Replace HERE WE ARE GOING TO INSERT AN EXPRESSION LATER with join(body('Select_HTML'),'') Get lead owners e-mail Since we already have the ID of the owner, we can use it to get the user details from the Users table in Dynamics Still inside the loop insert an Get a row by ID action and select the Users table. The row ID should be current item (and again, be carefull that you select the correct current item) Send mail Last we will insert an e-mail to the primary e-mail we got from the user table, and in our body we will insert the output from our compose action Final loop Here is how my final flow looks like E-mail This is how the e-mail could look like ‎ --- ### Temporarily skip an action in Power Automate Forfatter: Alexander Henkel Dato: 2024-05-21 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-05-powerautomate-skip-action-temporarily/ Learn how to temporarily skip an action in Power Automate with Static Result The challenge Sometimes, when building or testing a Power Automate flow, you might want to temporarily skip an action. For instance, you may not want an item to be created every time you run a test, or you might not need a part of your flow at the moment but don’t want to delete it. The solution Luckely we can skip an action with a few clicks 🚀 Here I have a simple flow that updates the Title each time a new item is created To skip the action Update item click on the 3 dots and select Static result (Preview) Click Enable Static Result (Preview) Don’t change anything, and simply press Done Notice how the action now has a flask symbol Now our flow don’t really do anything other than returning an OK You can turn on the action again by turning off Static Result Note that this trick does not work with all actions Shout-out to Pieter Veenstra (MVP) I had no idea that this was an an option (and it has been since 2020) until Pieter Veenstra re-shared his article from SharePains on LinkedIn, so big thanks to him for sharing 🚀 ‎ --- ### Show internal column names in SharePoint with Tampermonkey Forfatter: Alexander Henkel Dato: 2024-05-06 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-05-sharepoint-show-and-copy-internal-name/ Learn how to create a keyboard shortcut, to display a column’s internal name (along with a copy function) in SharePoint In an earlier post I described how you could make a keyboard shortcut that would take you to the list or library settings. Here I will give you an example of how you can display the internal column names of a list or library, including a function for copying the internal name. Here is a SharePoint list with two columns By pressing ALT + I on my keyboard, I’m now able to view the internal names of the columns Clicking on the copy 📋 icon enables me to easily copy the internal name to my clipboard. Here is the script in action If you don’t already have the Tampermonkey Chrome Extension, you can read in this previous post how to install and use the extension The code Here is the Tampermonkey script // ==UserScript== // @name SharePointInternalNames // @namespace http://tampermonkey.net/ // @version 1.0 // @description Show Internal Names in SharePoint List (with copy function) // @author Alexander Henkel - www.alexanderhenkel.dk // @match https://*.sharepoint.com/* // @icon https://img.icons8.com/color/48/000000/ms-share-point.png // ==/UserScript== function showNotification(message) { const notificationElement = document.createElement('div'); notificationElement.style.cssText = ` position: fixed; top: 50px; left: 50%; transform: translateX(-50%); padding: 10px; background-color: #a3e6b7; border: 1px solid #4caf50; border-radius: 4px; color: #4caf50; font-weight: bold; z-index: 9999; `; notificationElement.textContent = message; document.body.appendChild(notificationElement); setTimeout(() => { document.body.removeChild(notificationElement); }, 2000); } window.addEventListener("keydown", (event) => { if (event.altKey && event.key === "i") { const headers = document.querySelectorAll("[Role='columnheader']"); const gridcells = document.querySelectorAll("[Role='gridcell']"); for (const header of headers) { if (header.children.length > 1) { const displayName = header.children[1].children[0].children[0].innerText; const internalName = header.dataset.itemKey; header.dataset.internalName = internalName; header.style.position = 'relative'; const copyIcon = document.createElement('span'); copyIcon.innerHTML = '📋'; copyIcon.style.position = 'absolute'; copyIcon.style.top = '50%'; copyIcon.style.transform = 'translateY(-50%)'; copyIcon.style.left = '10px'; copyIcon.style.cursor = 'pointer'; copyIcon.title = 'Copy Internal Name'; copyIcon.addEventListener('click', (event) => { const target = event.target.closest("[Role='columnheader']"); if (target) { const internalName = target.dataset.internalName; navigator.clipboard.writeText(internalName) .then(() => showNotification(internalName + " was copied to your clipboard")) .catch((error) => console.error('Unable to copy internal name: ', error)); } }); header.appendChild(copyIcon); header.children[1].children[0].children[0].style.marginLeft = '20px'; header.children[1].children[0].children[0].innerHTML += `(${internalName})`; } header.style.width = (parseInt(header.style.width.replace("px", "")) * 1.5) + "px"; } for (const gridcell of gridcells) { gridcell.style.width = (parseInt(gridcell.style.width.replace("px", "")) * 1.5) + "px"; } } }, false); I am a low-coder, so the above script can probably be fine tuned - you can always contact me through x.com if you have any ideas or improvements to the script ‎ --- ### Filter the recycle bin in SharePoint, and restore with PnP/PowerShell Forfatter: Alexander Henkel Dato: 2024-05-04 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-05-sharepoint-filter-recyclebin-and-restore-copy-2/ Learn how to retrieve deleted items with PnP PowerShell, filter the recycle bin, and restore them The challenge If you have a site with a lot of activity, your have probably tried that a user have deleted an item/file that they were not supposed to. There is a recycle bin available for your site, but the filter / search options are limited. I have sites with 50+ users, and between 100-300 files are deleted everyday (on purpose with automations), but if a user has deleted an item by accident, it can be hard to locate that specific item/file. Solution We can use PowerShell 7 and the PnP PowerShell scripts to collect the deleted items, and thereafter filter the list If you have not all ready installed PnP PowerShell, your should do that by following this guide Connect to your site First we need to connect to our site by writing the following in PowerShell 7 connect-PnPOnline https://YOURTENANT.sharepoint.com/sites/YOURSITENAME -interactive If you don’t get any error after you have logged in, you are now connected to your site Getting the Recycle Bin list To get the list of deleted items, we are going to use the script that is documented here Your can run this command directly Get-PnPRecycleBinItem Or if you have many items, you can set a row limit by writing Get-PnPRecycleBinItem -RowLimit 1000 Here is (a part of) the results from my site Filtering your list From the recycle bin we are getting the following values returned Title Id ItemType LeafName DirName To begin filtering the list in PowerShell, we will first create a variable by writing $recyclebin = Get-PnPRecycleBinItem -RowLimit 1000 Now we have created a variable called $recyclebin which now have the list of items - you can test it, by just writing $recyclebin in your console $recyclebin Filter Our next command is going to filter our variable ($recyclebin) $recyclebin | Where-Object {$_.ItemType -eq "ListItem"} This command is taking the variable $recyclebin and asking it to return all objects where ItemType equals ListItem We can shorten the list even further by adding more filters $recyclebin | Where-Object {$_.ItemType -eq "ListItem" -and $_.DirName -eq "sites/Blog/Lists/Settings"} There are many examples of how you can filter your list, and you can find a lot more here In my example here I am down from 200 elements, to only 2 Restoring your item After you have located the deleted item you want to restore, we can restore it by using another PnP PowerShell command We are going to use Restore-PnPRecycleBinItem which is documented here One of the objects that is returned is a unique Id, so we can restore a specific item by writing Restore-PnPRecycleBinItem -Identity 3361f787-99e7-4c06-9555-4f15f73c64bc We will confirm by writing Y If I run the Get-PnPRecycleBinItem command again, and filter my items, I will only have one item left, since the second item has been restored Now you know how to find and restore items that have been deleted by accident - are you ready to become a hero among your colleagues? ;) PS. This is a very good example of why the Title column is a good idea to use when you are designing your site Happy restoring ‎ --- ### Make a keyboard shortcut to open list or library settings with Tampermonkey Forfatter: Alexander Henkel Dato: 2024-05-04 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-05-sharepoint-tampermonkey-open-list-or-library-settings/ Learn how to make a keyboard shortcut in SharePoint, using Tampermonkey Chrome Extension The challenge When working with a complicated site, you might find yourself going to the list or library settings many times, during development. I know it is only 3 clicks away, but why not make a keyboard shortcut for that task? Tampermonkey to the rescue Solution First you must download Tampermonkey from the Chrome store After installation select the extension in the top right corner and click Create a new script Insert the following code and click file -> save Is this code safe? Yes it is, trust me, random guy online - but honestly it is - maybe // ==UserScript== // @name SharePoint Library/List Settings // @namespace http://tampermonkey.net/ // @version 0.1 // @description Open the library or List settings by pressing ALT + L // @author Alexander Henkel / www.alexanderhenkel.dk // @match https://*.sharepoint.com/* // @icon https://img.icons8.com/color/48/000000/ms-share-point.png // ==/UserScript== (function() { 'use strict'; const closeButtonSelector = '[aria-label="Close pane"]'; const settingsSelector = '[aria-label="Settings"]'; const SettingsPane = '[id="FlexPane_Settings"]'; const librarySettingsSelector = '[aria-label="Library settings"], [aria-label="List settings"]'; const moreLibrarySettingsSelector = 'a[href*="listedit.aspx?List="]'; function waitForElement(selector, callback) { let element = document.querySelector(selector); if (element) { callback(element); } else { setTimeout(() => { waitForElement(selector, callback); }, 200); } } function showNotification(message) { const notificationElement = document.createElement('div'); notificationElement.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 10px; background-color: #ff9999; border: 1px solid #cc0000; border-radius: 4px; color: #cc0000; font-weight: bold; z-index: 9999; `; notificationElement.textContent = message; document.body.appendChild(notificationElement); setTimeout(() => { document.body.removeChild(notificationElement); }, 2000); } window.addEventListener("keydown", (event) => { if (event.altKey && event.key === "l") { const settingsPane = document.querySelector(SettingsPane); if (settingsPane) { document.querySelector(closeButtonSelector).click(); } const settingsButton = document.querySelector(settingsSelector); if (settingsButton) { settingsButton.click(); waitForElement(SettingsPane, () => { const libraryExist = document.querySelector('[aria-label="Library settings"]'); const listExist = document.querySelector('[aria-label="List settings"]'); if (libraryExist === null && listExist === null) { showNotification("Shortcut is not available on this page"); document.querySelector(closeButtonSelector).click(); return; } waitForElement(librarySettingsSelector, (libraryOrListSettings) => { libraryOrListSettings.click(); waitForElement(moreLibrarySettingsSelector, (moreSettings) => { moreSettings.click(); }); }); }); } } }); })(); You might have to refresh or open/close your browser the first time you want to run the script, but otherwise you should now be able to open your list or library settings in SharePoint by pressing ALT + L on your keyboard. You can change the keyboard shortcut in line 56 in the code Run Power Automate test I got the inspiration to write this Tampermonkey script from David Wyatt who wrote a lovely script that can help you to run a Power Automate tests simply by pressing ALT + T on your keyboard. I highly recommend you to install this script as well. It will save you a lot of time You can find it on his GitHub here Now it is time to save some time 🕧 ‎ --- ### April in M365 Forfatter: Dan Toft Dato: 2024-04-28 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-04-april-m365/ Exchange high volume emails If you’ve ever built a system that sends out a lot of individual emails, using Graph (or exchange), I’m sure you’ve inevitably run into the throttling limits, suddenly your app breaks because the user has sent too many emails, and you’re stuck with a broken app for the next couple of hours. And with nothing much to do, other than wait, and hope that the throttling limits are lifted. Well I’ve sometimes moved my solutions to third parties like SendGrid, but that’s always frustrating, because you’re now paying for the same service twice, and you’re also now managing two different services. Well no more, going into preview now is Microsofts take on high volume emails, and while it’s still in preview, I’m looking forward not having to manage two different services in the future. Read more Copilot in stream rolling out Have you ever wanted to get a quick summary of a video, before you decide to watch it, or maybe you remember watching it, but can’t remember the exact phrase, that was said? Well fear not rolling out now is Copilot in Stream, ask Copilot about that video you don’t have time to watch, or that meeting recording where you missed the first 15 minutes, or had to drop off early! Read more Interact with content while annotating teams As a heavy user of ZoomIt, I’ve never really used the annotate feature in Teams, as I just couldn’t see the value. Well that changes now! - when annotating with teams, you’ll now have the option to press a button and interact with your “background” without losing your annotations. Just imagine circling parts of a document, while talking about it, and then being able to edit the document, while the circling stays in place, that’s a game changer! Read more SharePoint Pages Graph API GA Basically since the launch of Modern SharePoint pages, we’ve been waiting for a proper pages API, there’s some features in CSOM, but not perfect, and while the PnP community has done an awesome job revere engineering the API, it’s always a balance act, especially when you’re building solutions for clients, where you really don’t want to use unsupported APIs. Well, the wait is over, the SharePoint Pages Graph API is now GA, and while it’s a limited release, only supporting a few select Web Parts, it’s a start, I’m certanly looking forward to seeing what the future holds for this API. Read more Power BI Premium licensing updates Previously you could purchase Power BI Premium per capacity, this is now moving towards Microsoft Fabric capacity, in Microsofts endless pursuit to push users to the latest and greatest. Honestly I’m not aware of any negative impacts this will have, other than you needing to move your workspaces from one capacity to another. Read more Viva Connections personalized view Viva Connections is great for bringing better communications to the frontline workers in your org, but I’m sure you’ve had the request to change the layout, or the order for 1 or 2 users, well now the users can do that themselves, I’m not really looking forward to how IT admins will handle this, but I’m sure it’s going to be a great feature for the users! Read more Brand center and custom fonts rolling out The number one request that I have been able to “hack”, but always, always, always have managed to talk clients out of is custom fonts in SharePoint, and while I don’t fully get the point of custom fonts, I do understand that it’s a branding thing, and that it’s important to some clients. So I really appreciate that Microsoft is rolling out custom fonts in SharePoint, and I’m looking forward to seeing how this will be used in the future. But personally I’m even more excited about the brand center, and what’s to come in the future, for SharePoint, Viva connections, and the rest of the M365 suite in the coming years! Read more TL;DR Microsoft seems to have slowed down a bit, but we’re also looking at the M365 conference and Microsoft Build coming up, so I’m sure we’ll see a lot of new features in the coming months, and I’m looking forward to seeing what’s next for M365! --- ### How to trigger a Power Automate Flow with a custom button in Dynamics 365 Forfatter: Alexander Henkel Dato: 2024-04-23 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-04-model-driven-app-start-power-automate-flow/ Learn how to start a Power Automate flow in a model driven app (Dynamics 365) To see a solution without using Ribbon Workbench, read this post instead The challenge In Dynamics 365 there is currently no out-of-the-box solution to start a Power Automate flow from a custom button, and the current version of the modern ribbon customization is not as good (yet) as the Ribbon Workbench developed by my hero Scott Durow What we want to achieve in this exercise is to create a flow that allows a sales person to create a new item in SharePoint, directly from CRM. The flow should take information from CRM and use it to create the SharePoint item, and could also write information back to CRM Solution Installing XrmToolBox and Ribbon Workbench The first thing you need to do (if you don’t already have the tool), is to download XrmToolBox and install it locally on your PC. Once the tool is installed, navigate to Open Tool Library in the top right corner Search for Ribbon Workbench, and install it Connect to Dataverse Before you run the tool we need to create a connection to Dataverse Click on the connect icon in the top left corner, and click new I prefer to use the Connection Wizard ‎ When you are creating the connection, make sure to change the timeout to a minimum of 4 min. It usually takes more than 2 min. to publish your solution, and if you only have a 2 min. timeout, you will typically end up with an error. Create a new solution Ribbon Workbench requires an almost empty solution, so before you open the tool, go to make.powerapps.com and create a new solution (one for each table/entity you want to change or a maximum of 3 tables/entities) Name the solution something like ribbon_tableName I have named my solution ribbon_contacts On the next page, select add existing and click Table Select the table from where you would like to change the ribbon (or start the flow) and click next. In the next window make sure that you don’t select Include all objects or Include table metadata Once you Solution is ready, we can finally open Ribbon Workbench. When the tool opens you will see below prompt, and you can now connect to your new solution ‎ Ribbon Workbench is a very powerful tool, and I advice you to play around, or come back to this blog for more posts about how to use the tool. Installing Smart Buttons To start a flow from Dynamics, we need to use Smart Buttons, because one of them can help us to start a webhook To install the Smart Buttons go to Scott Durow’s RibbonWorkBench @ GitHub and download the zip file Develop1SmartButtons_1_2_638_1.zip What you have just downloaded is a Managed Solution - it contains the Javascript that will help to create and run the Smart Buttons Go back to make.powerapps.com and import the solution/zip file When you solution has been installed, re-open Ribbon Workbench in XrmToolBox, and the Smart Buttons should now be available Add the Webhook button First we should drag and drop a run webhook button into our ribbon. We have 3 different ribbons Home Subgrid Form We will drag the button to form, meaning that the button will only be visible when the record is opened In the popup window give your button a title and click OK Next we will create the Weeb Hook Url and Success Callback Javascript functions Web Hook Url Go to make.powerautomate.com and create a new Automated cloud flow As a trigger you should use When a HTTP request is received Notice that here is a security risk. Since anyone who (potentially) has this URL could start your flow. Ribbon Workbench does not currently support if you change the trigger to Any user in my tenant or Specific users in my tenant In Request Body JSON schema insert the following code { "type": "object", "properties": { "id": { "type": "string" } } } This code will get the ID of the contact person. We can use this ID to receive the remaining information about the contact by using Get a row by ID action Next insert a new action called Response and insert 200 in Status code You can now add a new action (before response) to your flow, to create a new item in SharePoint (or whatever you prefer), since you have all of the information for the contact/opportunity/lead from your Get a row by ID action. Webhook URL You need to save your Power Automate flow before you can get the HTTP POST URL. Once it has been saved, copy the URL from your trigger action, and insert it into Ribbon Workbench Success Callback Javascript We would like to give the user a notification that the contact has been created successfully, so let’s add the following Javascript, that will create a popup window for the user when the flow has finished var alertStrings = { confirmButtonLabel: "OK", text: "The contact has been created in SharePoint", title: "SharePoint contact" }; var alertOptions = { height: 120, width: 260 }; Xrm.Navigation.openAlertDialog(alertStrings, alertOptions).then( function (success) { console.log("Alert dialog closed"); }, function (error) { console.log(error.message); } ); We don’t need to create any error fall back, since a red error panel will be shown in case the flow fails. However, if you are creating more advanced flows we might want to handle an error with a 400 response in our Power Automate Flow. Publish We now have a new button available, and we are ready to hit the Publish button - be patient, it can take up to 4 min. before the solution has been published. To see the button in CRM you might want to hit CTRL + F5 a couple of times. Here is our new button Here is the confirmation dialogue, to confirm that the flow should run Here we are showing the user that the flow is now running And here is the dialogue once the flow has finished There you go, you now know how to start a flow from a custom button - there are other ways we can achieve a similar behaviour, but we will cover that in another post :) ‎ --- ### Getting to PnP Framework logs Forfatter: Dan Toft Dato: 2024-04-16 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-04-pnp-framework-logs/ Introduction If you’re anything like me, you’ve been using PnP Framework for provisioning basically forever, and everything has been awesome! Normally this approach has been great, and whenever there’s been an issue I would debug locally, and keep an eye on the output in Visual Studio And that worked great, however recently I’ve had more and more fails in production, due to my Azure functions timing out. Not great, and really hard to reproduce locally, I could spin up Dev Proxy - but since it’s not a matter of my code failing, but rather just randomly taking waaay longer than normally, it’s not really helping me. Time to look at the logs! Why logging? Well, logging is the first step to understanding what’s going on, and what’s suddenly taking so long. So let’s take a look at how we can get access to the logs from PnP Framework. Logging in PnP Framework PnP Framework unfortunately doesn’t have a very intuitive way of enabling logging, since unfortunately it uses TraceLogging, which is a bit of a pain to get to work with, but it had to be possible. My first stop was PnP.PowerShell, where the awesome folks had already implemented a way to enable logging, and it was just a matter of copying that code: ConsoleTraceListener consoleListener = new ConsoleTraceListener(false); consoleListener.Name = ConsoleListenername; Trace.Listeners.Add(consoleListener); source Or so I thought, turns out the approach PnP.PowerShell uses only allows to log to the console, or a file, and I wanted to log to Application Insights. Damn, this didn’t give a lot to go off of, but after a bit more back and forth, I managed to get it working, by making my own trace listener: public class LoggerTraceListener : TraceListener { private readonly Microsoft.Extensions.Logging.ILogger _logger; public LoggerTraceListener(Microsoft.Extensions.Logging.ILogger logger) { _logger = logger; } public override void Write(string message) { _logger.LogInformation(message); } public override void WriteLine(string message) { _logger.LogInformation(message); } } Then from there, using just two lines I could start to get logs in Application Insights: Trace.Listeners.Add(new LoggerTraceListener(logger)); PnP.Framework.Diagnostics.Log.LogLevel = PnP.Framework.Diagnostics.LogLevel.Debug; And now I could see all the logs in Application Insights, and I now have a much better idea of what’s going on, and why my Azure functions are suddenly taking so long. TL;DR Steal the code snippets above, and remember to enable logging before you go to production, it will save you a lot of headaches! --- ### Create a date table in Power BI Forfatter: Alexander Henkel Dato: 2024-04-13 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-04-powerbi-date-table/ Learn how to create a date table in Power BI The challenge Power BI already has a build in time intelligence, but when you are building more complex reports, you don’t really have any ownership of the dates. When I am reading what other people are saying online, most people recommend that you turn off the time intelligence in Power BI You can turn off the time intelligence globally in Power BI under settings Or you can do it on the current file (this is what I prefer instead of globally, because if you just want to test something or make a very simple report, the time intelligence can be OK) The solution There are many suggestions on how you can build a date table. These are my 2 preferred solutions. DATE TABLE 1 Power Query (M) table (complex table) This table is written by Melissa de Korte who is a co author of the book The Definitive Guide to Power Query You can find the code in this forum and make a copy of the code In Power BI select Get data -> Blank query Rename your query to DateTable and then click on Advanced Editor Replace the entire code in the new window, with what you just copied, and click done Here you can insert the StartDate and EndDate, and if you are working with a company that does not have the financial year from 1/1 to 31/12, you can change that in the FYStartMonthNum Click Invoke, and rename your new table to dimDate Now we should combine our new date table with our fact table. In the Model view drag the field date from your dimDate to your fctTable (not the other way around, notice the way the arrow points) We now have a very powerful date table - I will follow up on this blog how we can utilize from this table to solve otherwise complex challenges in Power BI DATE TABLE 2 Third party plugin - Bravo There is a third party tool called Bravo, created by sqlbi The tool can help you to analyze your model for performance, it can format you DAX, and it can create a date table for you. After you have downloaded the software, you can run it directly from your start menu, or you can find it inside Power BI under external tools When opening the tool inside Power BI, it will automatically attach to your file. The tool is very user friendly, and all you need, is that you navigate Manage Dates and select the setting you prefer. Notice that you might want to change the Regional format under Dates otherwise you will end up with American dates Note that you need to turn of the time intelligence in your Power BI file, before you can add a date table with Bravo The Bravo date table is not as complex as the Power Query Editor M table, but it is a lot more user friendly to apply (and if your report is not complex, no reason to overdo anything) Happy dating ‎ --- ### SPFx Audience targeting Forfatter: Jeppe Spanggaard Dato: 2024-04-09 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2024-04-role-based-rendering/ Audience Targeting 🎯 Audience targeting allows content administrators to direct content toward specific groups of users. This is particularly useful in organizations with a wide array of users, where not all content is relevant for everyone. By using audience targeting, you can improve the relevance of the content each user sees, leading to a more personalized and focused user experience. Audience Targeted Wrapper 🔧 Let’s dive into how to build an SPFx component that employs audience targeting. This component acts as a wrapper, showing or hiding content based on the user’s membership in specific groups. Group Check Logic 🔍 At the heart of our component is the logic that determines if the current user is a member of any of the specified groups. Group Check Logic public async isMember(group: any, context: any): Promise { const _cacheName = 'CacheName_memberGroups'; if (group == null || group == '') { return true; } const graph = graphfi().using(SPFx(context)); let _cachedMemberGroups = sessionStorage.getItem(_cacheName); let memberGroups: string[] = []; if (!_cachedMemberGroups) { memberGroups = await graph.me.getMemberGroups(); sessionStorage.setItem(_cacheName, JSON.stringify(memberGroups)); } else { memberGroups = JSON.parse(_cachedMemberGroups); } // "c:0o.c|federateddirectoryclaimprovider|xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx" const groupID = group.id.split('|')[2]; if (memberGroups.filter((g: string) => g === groupID).length > 0) { return true; } throw new Error('User not found'); } The wrapper component is visually minimalistic, primarily designed to function based on the logic of accessing group IDs from an array of IPropertyFieldGroupOrPerson. Its simplicity belies its utility, enabling dynamic content visibility tailored to user or group permissions with little visual footprint. TargetAudience wrapper export interface ITargetAudienceProps { pageContext: PageContext; context: any; groupIds: IPropertyFieldGroupOrPerson[]; } const TargetAudience: React.FC = ({ pageContext, groupIds, children, context }) => { const [canView, setCanView] = useState(false); useEffect(() => { const checkUserIsAllowedToViewWebpart = async () => { let proms: any[] = []; const errors: any[] = []; groupIds?.map((item : any) => { proms.push( isMember(item, context) ); }); Promise.race( proms.map(p => { return p.catch((err: any) => { errors.push(err); if (errors.length >= proms.length){ throw errors; } return Promise.race(null); }); })) .then(val => { setCanView(true); }); }; checkUserIsAllowedToViewWebpart(); }, [groupIds, pageContext]); return (
{groupIds != null && groupIds.length >= 1 ? (canView ? children : '') : children}
); }; export default TargetAudience; How to use it

This is top secret for non admins...

Final Thoughts 💭 Could the logic be extended to check that only selected users can view the content, instead of it being limited to groups? Absolutely, this is entirely possible. However, I’ve chosen to focus on the group aspect because that’s often what my solutions revolve around. This approach emphasizes the flexibility of SPFx solutions in catering to diverse requirements, allowing for both broad group-based targeting and the precision of user-specific content visibility. It highlights the potential to tailor your SharePoint solutions even more closely to your organization’s needs, ensuring that every piece of content reaches exactly the right audience. --- ### Extract/parse JSON code in Power BI Forfatter: Alexander Henkel Dato: 2024-04-04 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-04-040424-powerbi-extract-json-code/ Learn how to extract JSON code into single columns in Power BI The challenge If you are provided with a column containing JSON code like the picture below, you want to split up the code into single columns, to be able to work with the data in Power BI I have seen some scenarios where peoples first choice is to use the split column -> delimiter function, and then splitting by , Don’t to that, because you will end up with columns that looks like this I am sure what you want them to look like is this The solution In the Power Query editor there is a function available under transform -> JSON After you have transformed your data, your column will now look like this Click on the arrow and select all the columns that are relevant. If there are data/columns that you don’t need, leave them out (you can always add them later) Also unselect Use original column as prefix Now your JSON data will be split into separate columns, and you can continue working on your model ‎ --- ### Create dynamic/incremental numbers in SharePoint Forfatter: Alexander Henkel Dato: 2024-04-02 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-04-020424-sharepoint-increment-number-column/ Learn how create a dynamic number column with incremental values - ideal for project numbers The challenge SharePoint does all ready have an incrementing number column - the ID field. You can’t really control the ID field, or change the increment value, or raise the start value. The solution I like to use Power Automate to create the number. It gives me full control of how I would like the number to look like, and I can mix it with other values. The only down site is that the number is not created instantly. We will mix in some JSON column formatting so that the user won’t notice. Create a new list called settings and add two new columns ProjectNumber (Number) Comment (Text) By adding a comment to the settings list, we can use this list for other settings as well. I like to add these type of lists under an Administration label in my menu Power Automate When an item is added to my projects list I will use Power Automate to take the current value from the settings list, use that number to create my project number, and then raise the value in the settings list. We will create a simple number, but also a bit more advanced number that contains the year, number & everything before @ in the users e-mail Create a new automated cloud flow and use the trigger When an item is created Initialize an empty float variable Use an Get item action. In my settings list my number has Id 1 - yours could be different Concurrency control If the users are creating two projects at the same time, the flows will run simultaneously, so you will end up with the same project number. To avoid this behaviour click on the 3 dots on your when an item is created trigger and turn on concurrency control and change the value to 1 This will make sure that one flow is finished before the next will run. The only down site is that we can’t control which will run first Now we will update the projects list. The simple version is to just insert the ProjectNumber value that we just got from the settings list. We can also make the number a bit more advanced, like I did in the ProjectNumberString column. This is just an example, and can be done in many ways Year formatDateTime(utcNow(),'yy') The letters before @ of the creators e-mail first(split(triggerOutputs()?['body/Author/Email'], '@')) The last thing we want to do is to raise the number in the settings list. You can change the number in the increment variable to whatever you prefer JSON formatting The Power Automate is not triggered instantly, so let’s add some JSON formatting to our column, so that the user won’t just see an empty value The code will check if the field value is empty. If it is empty it will show the value auto-created if not it will show the value of the field with @currentField { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "children": [ { "elmType": "div", "style": { "display": "flex" }, "attributes": { "class": "ms-fontColor-neutralPrimary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "span", "txtContent": "=if(@currentField == '', 'auto-created', '@currentField')" } ] } ] } Now you know how to create a dynamic number in SharePoint - Happy Power Automating :) ‎ --- ### March in M365 Forfatter: Dan Toft Dato: 2024-03-28 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-03-march-m365/ March is coming to an end, for me March meant my first ever MVP summit, an experience like nothing I’ve ever tried before, I heard so much awesome stuff, unfortunately I can’t blog about that stuff just yet, what I can do is give you a summary of all the stuff that is publicly available, so here’s a summary of the stuff that’s happened in the world of M365 in March 2024. Copilot rolling out for multiple languages If, like me English is your secondary language, I guarantee you’ve had the same Copilot experience as me, This is cool, now it only it actually understood my content - well it looks like that’s all about to change! The list of upcoming languages has officially been announced, over March and April, the following languages will be rolling out: Arabic, Chinese Traditional, Czech, Danish, Dutch, Finnish, Hebrew, Hungarian, Korean, Norwegian, Polish, Portuguese (Portugal), Russian, Swedish, Thai, Turkish and Ukrainian. How cool is that I personally can’t wait to be able to actually use Copilot with my real content, and for more than 20% of my emails 🙌 - I’m super stoked for this one! Read more Section Backgrounds All the way back at M365 Conf last year, we were promised a whole lot of changes to the SharePoint page layouts, this is one of those changes, being able to have more than 3 different background colors is awesome - unfortunately we won’t be able to change to just any color (yet), but there’s a nice preset of colors/gradients to choose from. We’ve also been promised that we’ll be able to upload our own images sometime down the line, if that’s the case you could just upload a solid color image, and have any color you want, which just feels like a hack, but is a workaround, like we often end up doing with SharePoint pages. Read more Power Automate Version history Have you ever been working on a Power Automate flow, and ended up breaking something that was working earlier that day? - I mean, I certainly have never ever done that 😅 - but hypothetically, if you were to do so, you’ll soon have the option to roll back with the upcoming version history for your flows, how awesome is that? 🎉 Read more Changes to app launcher A minor change, but none the less, it’s worth it to be aware of what’s coming, when you click the waffle menu in Microsoft 365, you’ll soon see a new look, with a wider panel, and a new layout, and easier access to create new content! Read more Office Scripts available to lower tier licenses Basically since I was born people have been screaming about macros in Excel, and all the security issues that came with them, a few years back Microsoft launched Office Scripts, a great alternative to macros, one of my first blog posts was on this topic it’s an awesome feature, with the main issue being that it was only available to E3 and above, well that’s all about to change, Office Scripts is now available to E1 and F3 licenses and above, how cool is that? Bringing automation to the frontline workers, it’s really nice to see Microsoft taking frontline workers seriously and giving them the tools they need to be more productive. Read more Custom list forms rolling out A really really really cool feature that was announced all the way back in may last year is the ability to have custom forms in SharePoint, this is finally rolling out, right now! - It’s already hit my dev tenant, I’m really looking forward to seeing some of the awesome things people will use this for! - from gathering a list of attendees for the Christmas party, to a “hardware order form”. Read more Changes to user profile image sync If you’ve ever struggled with SharePoint and user profile images, you might’ve gone down the rabbit hole that is user profile images - it looks like Microsoft is finally taking solving this issue seriously, instead of syncing the images around from Entra to Exchange, and then to SharePoint, syncing is no more, from now on all images will be fetched directly from Graph! While this is really cool, it will affect some of the custom solutions that are out there, so be sure to check if your solution is affected by this change, are you fetching images from Graph, or the user profile service? Another thing this affects is guest users - today guests don’t get a picture synced to SharePoint, but admins could upload one to the user profile service, this will no longer be possible, so if you’re using images with guest users, be sure to check if this affects you! Read more RSS (Restricted SharePoint Search) - Limit Copilot from accessing your data In the era of Copilot, we’re seeing similar scenes to what (I’ve heard) happened in the days of delve - users are suddenly discovering that they have access to things they had no idea they had access to - security by obscurity is now starting to show its flaws, this has led to many organizations scrambling to figure out how to clean up their permissions. Well Microsoft has given them a quick-fix, Restricted SharePoint Search - or as Microsoft has decided to call it RSS - this feature will allow you to make a “allow list” of sites that Copilot can access, you can select up to 100 sites. While I appreciate that loads of orgs want Copilot, and don’t feel like taking up the effort that is cleaning up your data, from my personal point of view, this is like pissing your pants to stay warm, this should be a last resort, not your go-to approach to fixing your permissions. Read more Public Preview, new Planner A little while back at ignite Microsoft announced plans to consolidate the Todo, Planner and Project for the Web into “the new planner”, and instead of you having to worry about different apps, the different features would be locked behind licenses, well the first step in this journey is now in public preview, the teams app “Planner” is now in public preview, I’ve played around with it a bit, and it looks to be working quite alright, it’ll be interesting to see what happens to the existing services over the coming months, are we going to see planner, todo and project for the web go away completely, or will they still be around for a while? Read more File viewer improvements Microsoft are working hard on improving some of their core features in this era of Copilot, one of those is the file viewer, you know from SharePoint/OneDrive, while it already supports over 300 file types, Microsoft is still working to improve it, they’re adding things like Copilot integration, eSignatures if you’re a SharePoint Premium user, PDF editing, and a whole lot more! - a lot of these features are already available, and some are coming soon, but mostly this blog post is a roundup of all the changes that have happened over the last few months, or is coming soon, so take a look and get caught up. Read more Create from templates in OneDrive In word when you create a new document, you’re presented with an option to choose from a list of templates, this is a feature that I’ve really been missing when creating documents in SharePoint, and something I’ve often had to create solutions to work around, and while we’re able to add them using content types, what about OneDrive? - well it looks like Microsoft is bringing the template feature you know and love from your office apps into OneDrive - how cool is that? Read more TL;DR Microsoft’s focus is in no doubt on Copilot, but at the same time it’s really nice to see a focus on improving some of the just “everyday” things like the file viewer, and the app launcher. So even in what felt like a “slow month” a lot of cool stuff has happened, and I can’t wait to see what April brings! --- ### Warn the user if a file is broken (not unzipped) Forfatter: Alexander Henkel Dato: 2024-03-23 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-03-230324-formatting-broken-zip-files/ Learn how to warn the user if a file is broken (usually not unzipped) in a SharePoint library The challenge Drag and dropping files is a huge benefit of SharePoint libraries, but if the file dragged from a zipped file, it will look OK to the user, but actually it is not The solution Let’s create a JSON view formatting that warns the user by changing the color of a broken file to red In the file name we will show a warning icon, and if the user hover the mouse over the icon we will tell them what is wrong The code To change the color of the broken file to red go to format current view Insert following code { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/view-formatting.schema.json", "additionalRowClass": "=if([$File_x0020_Size] == '0', 'sp-field-severity--blocked', '')" } To add the warning icon click on your column name -> column settings -> format this column Insert following code { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "background-color": "transparent" }, "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Warning", "class": "ms-font-l" }, "style": { "padding-right": "6px", "display": "=if([$File_x0020_Size] == '0', 'inline', 'none')" }, "customCardProps": { "openOnEvent": "hover", "formatter": { "elmType": "div", "children": [ { "elmType": "div", "style": { "padding": "15px 45px 15px 15px" }, "children": [ { "elmType": "div", "style": { "margin-bottom": "5px" }, "children": [ { "elmType": "span", "style": { "font-size": "16px", "font-weight": "bold" }, "txtContent": "Your file is broken" }, { "elmType": "div", "style": { "font-size": "14px", "margin-top": "10px" }, "txtContent": "='Your file ' + @currentField + ' must be unzipped before uploading'" } ] } ] } ] }, "directionalHint": "rightCenter", "isBeakVisible": true } }, { "elmType": "span", "txtContent": "@currentField" } ] } There you go - we can now help/warn the users if they forgot to unzip a file ‎ --- ### Power BI - Calculate if time is within or outside working hours Forfatter: Alexander Henkel Dato: 2024-03-15 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-03-160324-powerbi-time-within-working-hours/ Learn how to determine whether a given time and date falls within or outside specific working hours The challenge We have a list of Alarms that is registered in a SharePoint list, and we want to know if the alarm was within or outside working hours which is Mon-Thu from 07:00 to 15:00 Fri from 07:00 to 12:00 Sat-Sun closed SharePoint list First we create a SharePoint list, that we can work with, within Power BI Power BI solution We are going to solve the challenge directly inside the Power Query editor, so we don’t have to write any DAX Time Zone Challenge I always seem to have a time zone challenge when I am working with time inside Power BI against a SharePoint list. Power BI seems to work with UTC, and since I live in another time zone, my data is always 1 hour behind. To solve this issue, I am creating a custom column with the following code DateTimeZone.RemoveZone(DateTimeZone.ToLocal(DateTime.AddZone([Alarmtime],0))) Use transform to change the type to “time only” So far my Power Query looks like this Split date and time Create a duplicate of the Alarmtime column and adjust it to display only the date. Splitting the date and time into two separate columns can make data management easier The code / solution Add another custom column and use the following code if ( ( [Alarmtime time] > Time.FromText("07:00:00") and [Alarmtime time] < Time.FromText("15:00:00") and ( Date.DayOfWeek([Alarmtime date]) <> 5 and Date.DayOfWeek([Alarmtime date]) <> 6 ) ) or ( Date.DayOfWeek([Alarmtime date]) = 4 and [Alarmtime time] > Time.FromText("07:00:00") and [Alarmtime time] < Time.FromText("12:00:00") ) ) then "Within working hours" else "Not working hours" Here is a printscreen from Power Query where I am adding the custom column Note that the first day of the week is week day number 0 so Friday is weekday 4 Instead of returning Within working hours and Not working hours you can also use true and false but I think the naming is better for the users when we are creating the visuals in Power BI Here is our current Power Query setup. Users can now begin filtering to determine if the alarm falls within or outside of working hours In the picture below you can see the day of the week and also the day number Now we can create a nice visual for the users who can filter on the working hours value Note how the working hours around 15:00:00 and 12:00:00 are calculated? You might want to fine tune the code by using =< or write the time as 06:59:59 (I think it is only when working for Space X that 1 second makes a difference) Not working hours Within working hours ‎ --- ### February in M365 Forfatter: Dan Toft Dato: 2024-02-27 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-02-february-m365/ Copilot for Microsoft 365 coming to Teams App on mobile If you’ve been lucky enough to be licensed to use Copilot for Microsoft 365, you’ve probably already fallen in love with the ability to quickly summarize things, draft messages and a like, soon these features will be coming to the Teams Mobile app! Read more Granular restore coming to M365 Backup As someone who’s been waiting forever for better backup tooling in M365 I was super stoked about M365 backup, not really for the Microsoft side of things, but more for the partner APIs, we all know that good backup strategy like 3-2-1 would prescribe that you keep your backup off-site, this could be with a third party partner who backs-up to somewhere like AWS. Unfortunately at the current state of M365 backup you can only restore a full site at a time, not a single file, however, it was “announced” that this will be coming later down the line. Hear more @ 02:40 Loop components in Microsoft OneNote If you’re anything like me, you’ve been playing around with loop, thinking “where does this put OneNote” - for me personally loop replaced OneNote for me in an instant, Loop components where like that, but on steroids. Well now you’ll be able to use loop components in OneNote, placing OneNote as a product in an even more awkward place 😅 Read more Limit presenter role Up until now When talking roles in a team call, you were either a presenter, or not, now some orgs don’t want presenters to have the option to disable attendees mics, or cameras. This is a tenant setting that admins can enable/disable depending on the organizations preferences. Read more Removing NoScript This change won’t affect a lot of users, but it’s a very important change - when doing certain “not-really-supported” customizations you have to use PowerShell to disable “noScript” - this is a feature designed to prevent ungoverned code from running in your environment, so SPFx solutions will be unaffected by this, unless they have set the requiresCustomScript property in the manifest, this means some community samples like the “modern script editor” will no longer be addable to a SharePoint site Note it’s possible to enable noscript for 24 hours at a time until March Read more Microsoft Graph SharePoint APIs now supports delegated Sites.Selected authentication SharePoint has since forever been very broad in their permissions scopes, however, back in 2022 we got an update that allowed us to scope application permissions - now that’s more important with application permissions, but maybe you have an app that uses delegated permissions (where the app acts on behalf of the user) you’re now able to limit the apps permissions to a few select sites, this is great for those cases where you might have access to a site, but don’t want your email archive app to be able to read data in your HR site. Read more SharePoint Premium Translations Honestly, I have no words, this is just cool! - using generative AI, you’re now able to translate files stored in SharePoint to over 100 languages, just simply right click the file and select translate, choose your language, and it’s off to the races! - all it takes is a SharePoint Premium subscription Read more Show hide buckets in Microsoft lists board view This is a very minor change, but ever since the launch of the Board view in Microsoft lists I’ve heard the same request from every one of my clients that use the feature! “Can we hide xyz bucket” - well now you can, I love these kinds of updates, very minor change, but very big impact to the users. Read more Viva Topics retirement Viva Topics is going to be retired on February 22, 2025 - I don’t particularly like this, viva topic is a really cool concept allowing you to organize organizational knowledge, well Microsoft believes so much in Gen-AI that they’re retiring topics in favor of Gen-AI. Time will tell if this was a good idea - I have my opinions. Read more Microsoft Lists in SharePoint new layout Another minor change that caused a lot of stir in my various channels was the updates to SharePoint lists that finally started to roll out, we saw this a bit last summer on some targeted release tenants, but that for whatever reason started to roll back about as fast as it had appeared. This new layout is more like what we’re used to from Microsoft lists, with more icons, and more space - and views now being tabs - I personally really like this change, but I have heard a bit of fuzz about some things, like view formatting breaking a bit, but I’m sure that’ll all be ironed out in the coming weeks. Read more TL;DR The Microsoft cloud is as always moving full steam headed! - and we’re not going to see that slow down any time soon, and as much as I hate the phrase 2024 really does look to be the year of AI! With that said, it’s obvious that Microsoft still has a huge focus on those small value things like the performance of Microsoft lists - it’s awesome to see they haven’t lost their focus! --- ### Web Parts not loading for guests on mobile Forfatter: Dan Toft Dato: 2024-02-19 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-02-web-parts-loading-guests-mobile/ So the other day a colleague of mine had what I can only describe as one of the weirdest issues I’ve ever seen in my years of working with SharePoint, so here’s a blog-post with the only purpose being I’ll find it next time I’m having the same issue. We had an issue where some users were reporting a web part not loading on mobile, oh well might be something with the responsiveness of the web part, just some CSS error, right? Well, as it turned out, no - they got the “blue text” error, that every SPFx dev will know Reading the message … wait what? - the user can’t access the SPFx web part assets? - but why? - it works when the same user is on a desktop, and it works for other users on mobile, even our account, so what’s going on? The issue It turns out the issue is only happening to guest users, okay, that’s why we can’t reproduce it, let’s get ourselves a guest account and try again. “Awesome” we can now reproduce the issue, what’s interesting is that it’s also happening on PC if I use the browsers devtools to shrink the window to a mobile size, so it seems to be related to screen size and guest users 🤔 The solution After some more digging we noticed some 200 OK request for _layouts/15/AccessDenied.aspx to the app catalog, and those weren’t there when doing it with desktop layout. As little sense as this made it turned out that external sharing was disabled on the App Catalog Site Collection, and that the guest were given access via an Entra Security Group, and that group was not given access to the App Catalog Site Collection. Turns out those permissions are handled differently on mobile vs desktop, and that’s why it was only happening on mobile. Arguably there’s a bug here, given that the users could access a Web Part that they shouldn’t have access to - but that’s a different story. TL;DR Check if you have disabled external access to the app catalog. --- ### Web Api logging attribute Forfatter: Jeppe Spanggaard Dato: 2024-02-18 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2024-02-logging-before-execution-w-attributes/ Have logging ever irritated you? If yes, then you’re definitely not alone! Often when I work with Web APIs in C#, logging tends to take up a lot of space, making the code quickly look “ugly.” I like to log what parameters the method is called with, which controller the method is in, etc. Therefore, a method often looks like this for me, in a simplified way: [HttpGet("{id}")] public ActionResult Get(int id) { _logger.LogInformation($"Starting to find weather forecast with ID: {id}"); { ... } _logger.LogInformation($"Weather forecast with id {id} is fetched"); return new WeatherForecast(); } Output Starting to find weather forecast with ID: 234 Weather forecast with id 234 is fetched I definitely don’t find it visually appealing, and it contributes to my classes sometimes becoming extremely long. Therefore, I’ve been thinking that it should be possible to make it easier and also more elegant. It got me thinking about how you can do so many cool things with custom attributes, so why not create some nice logging with an attribute? And, as so many times before, almost anything is possible with C#; it’s mainly just a matter of having the idea for it. So what I’ve come up with is just an attribute that inherits from IActionFilter, since IActionFilter already implements OnActionExecuted and OnActionExecuting. [ActionLogger] // <-- Custom attribute [HttpGet("{id}")] public ActionResult Get(int id) { return new WeatherForecast(); } Output [GET][WeatherForecastController][/WeatherForecast/234] Executing with parameters: [id, 234] [GET][WeatherForecastController][/WeatherForecast/234] Executed How did I go about implementing it then? public class ActionLoggerAttribute : Attribute, IActionFilter { public void OnActionExecuted(ActionExecutedContext context) { ILogger? logger = GetLogger(context, context.Controller.GetType()); var httpMethod = context.HttpContext.Request.Method; var routeUrl = context.HttpContext.Request.Path; var controllerName = context.Controller.GetType().Name; if (logger == null) { return; } logger.LogInformation($"[{httpMethod}][{controllerName}][{routeUrl}] Executed"); } public void OnActionExecuting(ActionExecutingContext context) { ILogger? logger = GetLogger(context, context.Controller.GetType()); var parameters = context.ActionArguments; var httpMethod = context.HttpContext.Request.Method; var routeUrl = context.HttpContext.Request.Path; var controllerName = context.Controller.GetType().Name; if (logger == null) { return; } string baseMessage = $"[{httpMethod}][{controllerName}][{routeUrl}] Executing"; switch (httpMethod) { case "POST": // Get the type of body content var bodyType = context.ActionDescriptor.Parameters .FirstOrDefault(p => p.BindingInfo?.BindingSource?.DisplayName == "Body") ?.Name; // Get the value of bodytype var bodyContent = bodyType != null ? JsonSerializer.Serialize(parameters[bodyType]) : null; if (bodyContent != null) { logger.LogInformation($"{baseMessage} with body content: {bodyContent}"); break; } logger.LogInformation($"{baseMessage} without body content"); break; case "GET": if (parameters.Count > 0) { logger.LogInformation($"{baseMessage} with parameters: {string.Join(", ", parameters)}" break; } logger.LogInformation($"{baseMessage} without parameters"); break; default: logger.LogInformation($"{baseMessage} with parameters: {string.Join(", ", parameters)}"); break; } } private ILogger? GetLogger(FilterContext context, T type) { return (ILogger?)context.HttpContext.RequestServices.GetService(typeof(ILogger)); } } As mentioned, I simply created an attribute that inherits from IActionFilter, allowing me to override what should happen in OnActionExecuted and OnActionExecuting. In the specific code I’ve written for my test to see what’s possible, I might have gone a bit overkill – but then again, it’s just to test the limits. I don’t think in a “real” setup you’d want to log the entire received request. Best practice? I know it’s not the best practice or the least resource-intensive way to log, but regardless, I’m thinking of using this type of attribute in some future projects because it’s easy, quick, and doesn’t clutter the code. Often, for me, it’s just as good, as it doesn’t necessarily matter much for the solutions I create whether it takes 40ms or 80ms, but rather how quickly I can pinpoint the root cause when an error occurs. [Link to code] --- ### Create a dynamic HTML table for e-mails Forfatter: Alexander Henkel Dato: 2024-02-17 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-170224-powerautomate-create-html-table/ Learn how to create a dynamic table with SharePoint list data The use case Each week we want Power Automate to send out an e-mail to the manager with all of the projects that still has the status open. We want to present the data in a table style Since the number of open projects are not always the same, the table needs to be build dynamically. This is how my SharePoint list looks - 3 projects are Open and 2 projects are Closed This is the e-mail output we are aiming for Power Automate The first thing we need is to get all the open projects, by using the get items action, and then filter the status value with open If you would like the items to be presented in the specific order, use the Order By option Building the table rows Next we are going to insert a select action so that we for each value in our get items is building a table row. We are going to use inline styling for our HTML, since I experienced that f.ex. G-mail does not read the styling if we use standard CSS Here is the code for creating the table rows TITLE STATUS COMPANY In your select action write html in the left site, and on the right site, insert the code above. TITLE, STATUS & COMPANY should be replaced with your dynamic content. (refer to the picture above) With the select action we have build a table row for all of the values we received in our get items. Later on we are going to join all this code into a single string, so insert another select action From should be the output from our previous select action, but the Map value we have to write ourself as an expression: item()?['html'] Building the table Our next action is going to be a compose action Here you should insert the following code HERE WE ARE GOING TO INSERT AN EXPRESSION
Project name Status Customer
This code is building the table header, and is implementing all the table rows we made earlier - Again, we are building the table with inline styling, to be sure that it looks correct in most e-mails. Replace Project name, Status, and Customer with your own headlines. Join expression We are going to join all of the table rows that we created earlier in one long string, by using the following expression join(body('Select_HTML'),'') Finally we have build a dynamic table that we can insert into our e-mail like this Test out your new Power Automate, and enjoy your new dynamic table Here is my entire flow ‎ --- ### Avoid unnecessary loops in Power Automate Forfatter: Alexander Henkel Dato: 2024-02-12 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-120224-powerautomate-avoid-loops/ Learn how to avoid unnecessary loops when using a filtered get items action When you use the Get Items action with a filter, there are often situations where you know you’ll end up with only a single item. The thing is that the item will be stored in an array, meaning that when you use any of the properties from that “single” item, Power Automate will automatically insert a loop. Example Here we are using get items and we are filtering from a customer list, on the specific customer number This is my customer list This is the Power Automate action We know that we will only get one item returned - but what happens if we want to send an e-mail with the customer number? Power Automate inserts a loop, because in theory our get items would have more than one item How to avoid the loop We know that we only have one item in our array, so let’s write an expression to get a specific value from that item. Looking at the output from our get items action, we can see that the item is in body -> value -> 0 -> CustomerNumber Instead of inserting customer number from dynamic content, use following the expression outputs('Get_items')['body/value']?[0]?['CustomerNumber'] The expression explained We are getting outputs from Get items and then body -> value, and if there is an item ? we are getting the first value [0], and if there is an item ? we are getting the value from CustomerNumber In Power Automate we can now replace the dynamic content with the expression from above, and avoid the loop (either, delete the loop and insert the action again, or remove the dynamic content from your email body, and “drag” the email action out of the loop - and then delete the loop) Your action should now look like this After running the flow, your output should now look like this Getting all values from the first item This expression/trick is good to use, if you only need 1 or 2 values, but it takes a bit longer to write these expressions, instead of using the dynamic content Since we now know how to get a specific value from our Get items we can also get the ID. Insert a Get item action (a single item, not Get items) and in the Id property insert the following code outputs('Get_items')['body/value']?[0]?['ID'] This will return a single item, and now we can work with all of the values in the dynamic content, and avoid loops There you go, you now know how to avoid a loop when it is not needed, and your flows will be much more readable in the future ‎ --- ### Filter Term Store / Managed Metadata / Taxonomy in Power Automate Forfatter: Alexander Henkel Dato: 2024-02-09 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-090224-powerautomate-filter-term-store/ Learn about the Term store, and how to filter a Term store column in Power Automate What is the Term Store? The Term store (also known as Managed Metadata or Taxonomy) is what I would call an advanced choice list. The benefits is that you can provide choices in a treeview style. Another benefit is that the Term store can be accessed in your entire tenant (if you have a choice field created directly on your list, or as a content type, in can only be accessed on that specific site) The Term store has many names Term store Taxonomy Managed metadata If you have not worked with the Term store before you can find it in the admin center https://tenant-admin.sharepoint.com/_layouts/15/online/AdminHome.aspx#/termStoreAdminCenter Working with the term store Click on the 3 dots and Add term group Error message The first time I worked with the Term store in my tenant I got an error saying An unexpected error has occurred. Please try again later. - I thought that there might be some issues with Microsoft, until I found out that the error should say You are not a term store manager Add yourself as an admin, and add a new term group again, and instead of an error message, your should get a success message Start building your Term store in a tree structure - notice that the first name (the folder icon) is mainly for admin purpose in the Term store, or when adding your Term store/Managed metadata column. In my example below, the user will only see Animals and the available options - so add one more level then what feels natural. Don’t create your entire Term store set in one sitting, because you can’t drag and drop the options around. Create the structure you want with 1 or 2 options, and then create the Managed metadata column in your list, and check out how it looks. The Term store has options to copy, move, merge etc., but I think it depends on the situation - but don’t be afraid to fool around What the users see I have created a new list where people can choose their favorite animal With Managed metadata, the users have a tree like experience - and the terms can be reused on all lists in the tenant Filtering term store column in Power Automate If we want the get a list of all the items with cats the natural thing would be to use the ODATA filter, and use internal name eq cats internalname eq 'Cats' Animals eq 'Cats' But if we do that, then we get the following error The field ‘Animals’ of type ‘TaxonomyFieldType’ cannot be used in the query filter expression. Well that means it can’t be done? Or can it? If we don’t care about any specific column (which I think is OK in most cases) we can use this expression in our ODATA filter (TaxCatchAll/Term) TaxCatchAll/Term eq 'Cats' This will return all items where Cats is selected (notice we did not write a specific column name, so it returns items from all columns, so if anyone had selected Cats in Animals or Animals2 that item would be returned) We can take it even further There is a hidden list named TaxonomyHiddenList with all of the information available in the Term store. We can get it by using the Get items action and in the List Name option, select Enter custom value and insert TaxonomyHiddenList Note: The list is very big, so you should filter down the term you want. Here we are looking for anything with where Title eq Cats Let’s take the first term ID that is available in the filtered list, by using this expression and insert it into a variable outputs('Get_items_TaxonomyHiddenList')?['body/value'][0]?['IdForTerm'] Then we could filter our first Get items with the filter array Single item If you have a single item (Action: Get item) from a list - and would like to filter another list with that specific Term store ID - you can use TaxCatchAll/IdForTerm TaxCatchAll/IdForTerm eq 'your-item-name/TermGuid' Now you know how to work with the Term store, and how to filter in Power Automate. There are other ways to work with the termstore, see my other post: SharePoint-PowerAutomate-filter-termstore ‎ --- ### The mystery of isprovisioningComplete Forfatter: Dan Toft Dato: 2024-02-08 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-02-mystery-isprovisioningcomplete/ If you’ve ever been provisioning SharePoint sites you might’ve wondered why when using SDKs like PnP.Framework or PnP Core creating a site seemingly takes ~45 seconds, but why is that? - when the user presses the create site button in the SharePoint UI it’s done in a few seconds. The SPSiteManager API My initial thought was that the SharePoint UI was doing something special, so I did a bit of digging, and what I found was that to my surprise the SharePoint UI uses the SPSiteManager API - alright, that’s a publicly documented API, so I should be able to use that too, right? - this should be a quick PR to the PnP SDKs and boost that performance for everyone 🎉 The mystery of isprovisioningComplete Well then looking at the PnP.Framework source code, it’s already using the SPSiteManager API, welp then, why is it then slow? 🤔 After a bit more digging (okay, maybe more like scrolling a few more lines and reading the code) - I found that the SDKs are repeatedly querying the sites web object, and checking the status of isprovisioningComplete, a boolean value, but what is it? - what does it do? Well, I’ve seen it in the past, but never really cared, cause it wasn’t in my way, but now it is, doing a quick google search (I even tried Bing) basically gave me nothing, so I had to dig deeper. What now? Well as I didn’t really get any good results from my search, I got desperate, and took to X, where the lovely Vesa came to the rescue with some real insight, and not just guessing, like what I had been doing up until now. Well then, as it turns out, there might be a very good reason for you to wait for the site to be done creating, as things might not work as expected if you don’t. But in my particular case, I was planning to do a combination of creating a site with a site design, and then use PnP Provisioning to create a few site pages, as the Graph site pages API still was a bit new for this solution. So I managed to take my provisioning time from 48 seconds to create a site, and then 15-20 seconds to apply the template to a total of 25 seconds. Doing it with PnP.Framework This was how I was creating my site before: // 48386ms var site = await new SiteCollection(web).AddAsync(new SiteCollectionCreationInformation { Title = "My new site", Url = "mynewsite", Description = "My new site", Lcid = 1033, Template = "STS#3", Owner = "owner@tenant.com" }); What I found just reading through the source code was that the awesome folk who built the SDKs had already thought about this, and added a noWait parameter to the Create method, so I could just do this: // 2341ms var site = await new SiteCollection(web).AddAsync(new SiteCollectionCreationInformation { Title = "My new site", Url = "mynewsite", Description = "My new site", Lcid = 1033, Template = "STS#3", Owner = "owner@tenant.com" }, noWait: true); What a difference, but of course, beware that this doesn’t mean you’ve just shortened your provisioning time to nearly nothing - cause you might still have some issues adding content types, or other things, but from my experience adding site pages didn’t cause an issue, so what I ended up doing was extracting the WaitForProvisioningIsComplete method, and called that in my own code after I had added the pages, but before I started working with content types. TL;DR If you’re using PnP.Framework or PnP Core to create SharePoint sites, and you’re wondering why it’s slow, it’s because it’s waiting for the site to be done creating, to give you the best experience, you can speed it up by skipping that step using the noWait parameter, but beware that you might still have to wait for some things to be done, to not have funky behavior so be careful. In conclusion: The isprovisioningComplete is actually named pretty intuitively, it tells you if the site is done provisioning, who would’ve thought? 🤷‍♂️ --- ### Change the search bar with PNP Powershell Forfatter: Alexander Henkel Dato: 2024-02-06 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-060224-change-search-bar-on-site/ Learn how to change the behaviour of the search bar on your SharePoint site. I had a case where an entire Intranet was build on a Teams site, and the default search was Search this site The users wanted to use the search bar to search for colleagues (I know, there are many other ways you can do this), but users/colleagues where not shown when searching within a site (you have to click organization after you have searched, which is not very intuitive) I don’t think you should change the standard behaviour of SharePoint, but for this blog post, it’s a good introduction to what you can do with PNP-PowerShell cmdlets. First you need to install PNP.PowerShell, and note, it needs installation to be done using PowerShell version 7 After installation you can now connect to your SharePoint site using this command connect-PnPOnline https://tenant.sharepoint.com/sites/sitename -interactive Login with your credentials. PowerShell will not give you any feedback, but if you don’t get any errors, you are connected to your site. We are going to use the cmdlet Set-PnPSearchSettings, which has the options to change the search to Site Hub Tenant Use the following command Set-PnPSearchSettings -SearchScope Tenant Go to your page and hit F5 and see how your page now says Search in SharePoint You can change your site back to the standard search again using this command Set-PnPSearchSettings -SearchScope Site You can just close down PowerShell, to disconnect from your site or use the command Disconnect-PNPOnline This was a short introduction to PNP.PowerShell -> but be careful, it is really powerful, so always try first on a test site, and understand what you are doing :) There are many cmdlets, and I will try to cover the ones that are more low-code, in this blog ‎ --- ### Filter with date (ODATA) and add date formatting Forfatter: Alexander Henkel Dato: 2024-02-06 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-060224-powerautomate-odata-filter-date/ Learn how to use the ODATA to filter on date columns in SharePoint, and how to format your date When you are using the Get items from SharePoint, you can use ODATA to filter your query. Here are some examples of how to filter and format date fields Created today When a new item is created in SharePoint the internal name for the creation date is Created It holds the date and time If we want to get all the items that have been created today, we need to find all the items that have been created between 00:01 and 23:59:59 The ODATA expression is Created ge 'expression1' and Created le 'expression2' Replace expression1 with formatDateTime(utcNow(), 'yyyy-MM-ddT00:00:01Z') Replace expression2 with formatDateTime(utcNow(), 'yyyy-MM-ddT23:59:59Z') The expression should be placed in between the ' ' signs Convert time zone To convert the time to a local timezone, use convertTimeZone() This expression converts the timezone to my local time zone in Denmark formatDateTime(convertTimeZone(utcNow(), 'UTC', 'W. Europe Standard Time'), 'yyyy-MM-ddTHH:mm:ssK') You own date field If you have created your own date field (without time), the expression is a bit more simple Here I filter a date field with the internal name MyDate MyDate eq formatDateTime(utcNow(), 'yyyy-MM-dd') Get items X days ago To substract 4 days from a date field, use following expression formatDateTime(addDays(utcNow(),-4),'yyyy-MM-dd') Here is the ODATA filter MyDate eq 'expression1' Replace expression1 with formatDateTime(addDays(utcNow(),-4),'yyyy-MM-dd') The expression should be placed in between the ' ' signs Simply remove the - to add 4 days Formatting a date field In SharePoint/Power Automate my field MyDate has the following formatting "MyDate": "2024-05-31 To change the format from a single item (Get item) to 31-05-2024 instead, use the following expression formatDateTime(outputs('Get_item')?['body/MyDate'],'dd-MM-yyyy') To change the format from multiple items (Get items) use the following expression (this will change the date from the first item in the array) formatDateTime(outputs('Get_items')?['body/value']?[0]?['MyDate'],'dd-MM-yyyy') Let’s take it even further and add 5 days to the date formatDateTime(addDays(outputs('Get_items')?['body/value']?[0]?['MyDate'],5),'dd-MM-yyyy') Or remove 5 days formatDateTime(addDays(outputs('Get_items')?['body/value']?[0]?['MyDate'],-5),'dd-MM-yyyy') Empty date field If you are using a date field in an email and want to display the date in a specific format, but the user has not provided a date, you will encounter an error. Here is how to handle an empty date if(empty(triggerOutputs()?['body/MyDate']), null, formatDateTime(triggerOutputs()?['body/MyDate'],'dd-MM-yyyy')) Above expression is from triggerOutputs() (the trigger that started your flow) If you want to use a field from any other outputs, the expression should hold the name of that action (outputs) if(empty(outputs('get_item')?['body/MyDate']), null, formatDateTime(outputs('Get_item')?['body/MyDate'],'dd-MM-yyyy')) ODATA filters Here is a list of how you use the less than, equal to etc. in the ODATA filter: Equal To: eq Example: field eq value Not Equal To: ne Example: field ne value Greater Than: gt Example: field gt value Greater Than or Equal To: ge Example: field ge value Less Than: lt Example: field lt value Less Than or Equal To: le Example: field le value Logical AND: and Example: field1 eq value1 and field2 eq value2 Logical OR: or Example: field1 eq value1 or field2 eq value2 Grouping with Parentheses: ( ) Example: (field1 eq value1 or field2 eq value2) and field3 eq value3 ‎ --- ### SQL save changes w. trigger Forfatter: Jeppe Spanggaard Dato: 2024-02-03 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2024-02-trigger-in-mssql-for-version-history/ The other day at work, I had to work on an old solution (some C# web API with an SQL) that a colleague had created a long time ago. Part of this solution was to create a kind of version history of objects and who had made the individual changes. As I opened up the project, I saw some of the wildest code (in my opinion) I’ve seen in a long time to solve an otherwise simple problem😮. Without going too deep into what was done, I can say that the person who created it was very fond of reflection in C#. How would I have done it? Since I’ve worked a lot with SQL, it would be obvious for me to look at how to solve this directly in the database, rather than having to create a lot of generic C# code that almost no one would be able to read afterwards. So the obvious choice would be to create an SQL trigger on the exact table that should have a version history. So let’s take an example Let’s say we have a Person table like this: CREATE TABLE [dbo].[Persons]( [ID] [int] IDENTITY(1,1) NOT NULL, [Firstname] [nvarchar](255) NOT NULL, [Lastname] [nvarchar](255) NOT NULL, [Email] [nvarchar](255) NULL, [Phone] [int] NULL, [Address] [nvarchar](255) NULL, [ModifiedAt] [datetime] NULL, [ModifiedBy] [nvarchar](255) NULL, CONSTRAINT [PK_ID] PRIMARY KEY CLUSTERED ( [ID] ASC ) ) ON [PRIMARY] From this table, we want to log every time a change occurs, not just that a change happens, but also what individual value changes from and to. This should, of course, be stored in a table as well, and such a table could look like this: CREATE TABLE [dbo].[Persons_Changes]( [TriggerID] [int] NOT NULL, [Firstname_Old] [nvarchar](255) NULL, [Firstname_New] [nvarchar](255) NULL, [Lastname_Old] [nvarchar](255) NULL, [Lastname_New] [nvarchar](255) NULL, [Email_Old] [nvarchar](255) NULL, [Email_New] [nvarchar](255) NULL, [Phone_Old] [int] NULL, [Phone_New] [int] NULL, [Address_Old] [nvarchar](255) NULL, [Address_New] [nvarchar](255) NULL, [ModifiedBy] [nvarchar](255) NULL, [ModifiedAt] [datetime] NULL ) ON [PRIMARY] Now for the fun part🎉 - namely, the logic that triggers each time a row in the Person table is updated. The trigger is designed to only save the values that are actually changed; the others that remain unchanged are simply set to NULL. Trigger code CREATE TRIGGER TriggerPerson_AfterUpdate ON dbo.Persons AFTER UPDATE AS BEGIN DECLARE @ColumnOrdinalTotal INT = 0; SELECT @ColumnOrdinalTotal = @ColumnOrdinalTotal + POWER ( 2 , COLUMNPROPERTY(t.object_id,c.name,'ColumnID') - 1 ) FROM sys.schemas s INNER JOIN sys.tables t ON s.schema_id = t.schema_id INNER JOIN sys.columns c ON t.object_id = c.object_id WHERE s.name = 'dbo' AND t.name = 'Persons' AND c.name IN ( 'Firstname', 'Lastname', 'Email', 'Phone', 'Address', 'ModifiedAt', 'ModifiedBy' ); IF (COLUMNS_UPDATED() & @ColumnOrdinalTotal) > 0 BEGIN INSERT INTO dbo.Persons_Changes ( dbo.Persons_Changes.TriggerID, dbo.Persons_Changes.Firstname_Old, dbo.Persons_Changes.Firstname_New, dbo.Persons_Changes.Lastname_Old, dbo.Persons_Changes.Lastname_New, dbo.Persons_Changes.Email_Old, dbo.Persons_Changes.Email_New, dbo.Persons_Changes.ModifiedBy, dbo.Persons_Changes.ModifiedAt ) SELECT d.ID , CASE WHEN d.Firstname = i.Firstname THEN NULL ELSE d.Firstname END , CASE WHEN d.Firstname = i.Firstname THEN NULL ELSE i.Firstname END , CASE WHEN d.Lastname = i.Lastname THEN NULL ELSE d.Lastname END , CASE WHEN d.Lastname = i.Lastname THEN NULL ELSE i.Lastname END , CASE WHEN d.Email = i.Email THEN NULL ELSE d.Email END , CASE WHEN d.Email = i.Email THEN NULL ELSE i.Email END , i.ModifiedBy , i.ModifiedAt FROM inserted i LEFT JOIN deleted d ON i.ID = d.ID; END END GO Now there should be a row like these every time a Person is updated TriggerID Firstname_Old Firstname_New Lastname_Old Lastname_New … ModifiedBy ModifiedAt 1 Jeppe1 Jeppe NULL NULL … JSC 2024-02-03 11:30:19 1 NULL NULL Spanggaard Christensen … JSC 2024-02-03 11:35:23 And now just for fun - how can this be used in C#? First of all, we need to create the DTO classes, one for the Persons table and one for Persons_Changes. Unlike many, I hardly ever use scaffold with Entity Framework; I create the models myself. Persons class: [Table("Persons", Schema = "dbo")] internal class Person { [Column("ID")] public int Id { get; set; } [Column("Firstname")] public string? Firstname { get; set; } [Column("Lastname")] public string? Lastname { get; set; } [Column("Email")] public string? Email { get; set; } [Column("Phone")] public int? Phone { get; set; } [Column("Address")] public string? Address { get; set; } [Column("ModifiedAt")] public DateTime? ModifiedAt { get; set; } [Column("ModifiedBy")] public string? ModifiedBy { get; set; } } Persons_Changes: [Table("Persons_Changes", Schema = "dbo")] internal class PersonChange { [Column("TriggerID")] public int Id { get; set; } [Column("Firstname_Old")] public string? FirstnameOld { get; set; } [Column("Firstname_New")] public string? FirstnameNew { get; set; } [Column("Lastname_Old")] public string? LastnameOld { get; set; } [Column("Lastname_New")] public string? LastnameNew { get; set; } [Column("Email_Old")] public string? EmailOld { get; set; } [Column("Email_New")] public string? EmailNew { get; set; } [Column("Phone_Old")] public int? PhoneOld { get; set; } [Column("Phone_New")] public int? PhoneNew { get; set; } [Column("Address_Old")] public string? AddressOld { get; set; } [Column("Address_New")] public string? AddressNew { get; set; } [Column("ModifiedAt")] public DateTime? ModifiedAt { get; set; } [Column("ModifiedBy")] public string? ModifiedBy { get; set; } } DbContext: internal class TestDbContext : DbContext { public DbSet Persons { get; set; } public DbSet PersonChanges { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseSqlServer("CONNECTION_STRING"); } protected void OnModelCreation(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); } } Once we have this in place, we can start playing with the data in C#, and for fun, I’m thinking of creating a ’nice’ display of who and when the individual values were changed. And even though I started joking about reflection, I use it myself (when it makes sense)😜. internal static string FormatChange(T changeObj) { List> changes = new List>(); var properties = typeof(T).GetProperties(); var filteredProperties = properties .Where(x => x.Name.Contains("Old") || x.Name.Contains("New")); var distinctProperties = filteredProperties .Select(x => x.Name.Substring(0, x.Name.Length - 3)) .Distinct(); foreach (var property in distinctProperties) { var oldValueProperty = typeof(T).GetProperty($"{property}Old"); var newValueProperty = typeof(T).GetProperty($"{property}New"); if (oldValueProperty != null && newValueProperty != null) { var oldValue = oldValueProperty.GetValue(changeObj); var newValue = newValueProperty.GetValue(changeObj); if (oldValue != null && newValue != null) { changes.Add( new Tuple( property, oldValue?.ToString(), newValue?.ToString() )); } } } StringBuilder sb = new StringBuilder(); foreach (var change in changes) { if (change.Equals(changes.Last())) { sb.Append($"{change.Item1} from '{change.Item2}' to '{change.Item3}'"); break; } sb.Append($"{change.Item1} from '{change.Item2}' to '{change.Item3}' and "); } return sb.ToString(); } When we bring it all together in a test application like this: static void Main(string[] args) { Console.WriteLine("Hello, World!"); TestDbContext dbContext = new TestDbContext(); List persons = dbContext.Persons.ToList(); Person person1 = persons.First(); List personChanges = dbContext.PersonChanges .Where(x => x.Id == person1.Id) .OrderBy(x => x.ModifiedAt) .AsNoTracking() .ToList(); foreach (PersonChange change in personChanges) { Console.WriteLine( $"[{change.ModifiedAt}] {change.ModifiedBy} " + "updated the the properties " + FormatChange(change)); } } We get this output: [2024-02-03 11:30:19] JSC updated the the properties Firstname from 'Jeppe1' to 'Jeppe' [2024-02-03 11:35:23] JSC updated the the properties Lastname from 'Spanggaard' to 'Christensen' Is it then a better solution? I can’t answer that, but for me, it is - and that’s because I believe something like this belongs in a database and not in code. Also, for me, it’s much easier to read a trigger than a bunch of reflection code😏. --- ### Chrome extension - SharePoint Dev Tools Forfatter: Alexander Henkel Dato: 2024-02-03 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-030224-extension-sharepoint-dev-tools/ Learn how to get a list of columns including their display- and internal name There are several Chrome extensions that makes life a little easier when working with SharePoint. SharePoint Dev Tools is a tool, that I use to compare the Display Name with the Internal Name, and we can even get the type of the column. If you are working with JSON formatting, or on a list with many columns, this is a good way to get a good overview of all the columns available. If you are looking for a single internal name of a single column, the fastest way is to sort by or filter by that column, and grab the internal from the URL, but I still had many use cases where this extensions was helpful. Here is an example where I have sorted by column, so that I can grab the internal name from the URL How to use the extension When you have downloaded the extension, go to your SharePoint site, click on the extension icon and select Lists & Document Libraries In the search panel, make sure that only to URL to your site it written, otherwise you won’t get any hits (here is room for improvements for the tool), so your URL should be https://tenant.sharepoint.com/sites/yourpagename Now we will have a selection, of all of the available libraries and lists on your site. Select the relevant list, and now we can compare all of the internal names, their display names, and even the type A wonderful tool that I have found myself using in many occasions 👍 ‎ --- ### Add different icons to a choice column Forfatter: Alexander Henkel Dato: 2024-02-01 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-02-010224-formatting-add-different-icons-to-a-choice-column/ Learn how to show different icons, and be introduced to CustomCardProps In an earlier post I described how you could power up your SharePoint list by adding an icon to your list. Here I will give you an example of how you can add different icons depending on what choice the user has selected. The code { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "children": [ { "elmType": "div", "style": { "display": "flex", "align-items": "center", "margin": "3px" }, "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "span", "attributes": { "iconName": "=if(@currentField == 'A', 'Archive', if(@currentField == 'B', 'ReminderTime', if(@currentField == 'C', 'People','People')))", "class": "ms-font-l" }, "style": { "display": "=if(Number(@currentField) == 0, 'none', 'inline')" } }, { "elmType": "span", "txtContent": "@currentField", "style": { "margin-left": "6px" } } ] } ] } When you are adding icons to a choice list, it also requires a bit more maintenance, because every time you add a new choice, you also have to consider adding a new icon. There is a “fall back” icon (People), so there will always be an icon When you are adding an extra icon, remember to also place an ) in the end of code, here is an example with an extra icon "iconName": "=if(@currentField == 'A', 'Archive', if(@currentField == 'B', 'ReminderTime', if(@currentField == 'C', '6PointStar', if(@currentField == 'D', 'AddTo','People'))))" You can find all available icons on this page Add an explanation to the choice We can add a CustomCardprops to the code, to help the user a bit more. An example could be confidentiality - so if the choice is “Confidential”, the user can hover over the choice and read more about what “that means” { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "children": [ { "elmType": "div", "style": { "display": "flex", "align-items": "center", "margin": "3px" }, "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "span", "attributes": { "iconName": "=if(@currentField == 'AAAAAAA', 'Family', if(@currentField == 'B', 'PeopleAlert', if(@currentField == 'C', 'ReportWarning', if(@currentField == 'D', 'Warning', 'QandA'))))", "class": "ms-font-l" } }, { "elmType": "span", "txtContent": "@currentField", "style": { "margin-left": "4px" }, "customCardProps": { "openOnEvent": "hover", "formatter": { "elmType": "div", "children": [ { "elmType": "div", "style": { "padding": "15px 45px 15px 15px" }, "children": [ { "elmType": "div", "style": { "margin-bottom": "10px" }, "children": [ { "elmType": "span", "style": { "font-size": "16px", "font-weight": "bold", "text-align": "left" }, "txtContent": "=if(@currentField == 'AAAAAAA', 'AAAAAAA TITLE', if(@currentField == 'B', 'B TITLE', if(@currentField == 'C', 'C TITLE', if(@currentField == 'D', 'D TITLE' 'Confidentiality'))))" }, { "elmType": "div", "style": { "font-size": "14px", "font-weight": "350", "text-align": "left", "margin-top": "10px" }, "txtContent": "=if(@currentField == 'AAAAAAA', 'AAAAAAA TEXT', if(@currentField == 'B', 'B TEXT', if(@currentField == 'C', 'C TEXT', if(@currentField == 'D', 'D TEXT', ''))))" } ] } ] } ] }, "directionalHint": "bottomCenter", "isBeakVisible": true, "openOnEvent": "hover" "beakStyle": { "backgroundColor": "white" } } } ] } ] } Again this code requires you to update it if new choices is added to the column. You need to update the code in 3 places (marked in bold) First is the Icon, second is the Title of the CustomCardProps and the Third is the detailed text The CustomCardProps does also have a few options, which we can change. directionalHint decides how the card is shown, and there is quite a lot of options here bottomLeftEdge bottomCenter bottomRightEdge leftTopEdge leftCenter leftBottomEdge rightTopEdge rightCenter rightBottomEdge topLeftEdge topCenter topRightEdge isBeakVisible is the small “triangle”, and it can be set to false. It can sometimes be used if you are creating an “if” statement - if the card should be visible or not (since the beak will always be shown) openOnEvent can be changed to click so that the card is only shown if the user click on the text. The CustomCardProps is very powerful and can be used in other cases. For example we can use it to show a thumbnail of an image or file, but we will cover that in another post. ‎ --- ### Completed [Today] button Forfatter: Alexander Henkel Dato: 2024-01-30 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-300124-formatting-completed-today-button/ Learn how to change the value of a column into today’s date with a single click If you are using your SharePoint list as maybe a list of tasks, or it could be a list of projects, it could be a good idea to make an easy way for the user to mark the task/project as completed This button when selected will insert the current date into the column (so it is important that you create the column as a date column) If the user clicks the button by fault, they can click the date again and the date is removed. I like to have all of my content that the user can interact with as buttons. I found this example from the PNP List formatting Github which I really liked, so all I had to do was to implement it into my own style The PNP List Formatting has many examples, and they are free to use! I even added my own example to the repository { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px" }, "children": [ { "elmType": "button", "customRowAction": { "action": "setValue", "actionInput": { "DateCompleted": "=if(Number(@currentField) == 0, getYear(@now)+'-'+padStart(toString(getMonth(@now)+1),2,'0')+'-'+padStart(toString(getDate(@now)),2,'0'), '')" } }, "attributes": { "class": "ms-bgColor-themePrimary ms-bgColor-themeDarker--hover ms-fontColor-white", "title": "=if(Number(@currentField) == 0, 'Click to mark complete', 'Completed on ' + @currentField.displayValue)" }, "style": { "border": "none", "cursor": "pointer", "display": "flex", "align-items": "center", "justify-content": "left", "height": "30px", "width": "110px", "border-radius": "3px", "margin": "5px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "=if(Number(@currentField) == 0, 'EventAccepted', 'CheckMark')", "class": "ms-fontSize-16 ms-fontWeight-regular" }, "style": { "margin-right": "5px", "margin-left": "8px" } }, { "elmType": "span", "txtContent": "=if(Number(@currentField) == 0, 'Completed?', '@currentField.displayValue')", "style": { "margin-right": "8px", "flex": "none" }, "attributes": { "class": "ms-fontSize-12 ms-fontWeight-bold" } } ] } ] } You don’t have to do much to make this work, however it is important that you change the “DateCompleted”, into the INTERNAL name of your own column ‎ --- ### January in M365 Forfatter: Dan Toft Dato: 2024-01-29 URL: https://pinksky.dk/blog/forfatter/dan-toft/2024-01-january-m365/ January held some personal achievements for me! - I was awarded the Microsoft MVP award for the first time, which is a huge honor and I still can’t quite believe it, I still haven’t quite had the time to process it, as I’m in the middle of a move on the home-front, but once that’s over I’ll be back at full steam and I’m looking forward to it! Now let’s get to looking at what’s happened in the M365 space in January! Copilot for all! For what felt like a very long time (but was actually only a few months), Copilot for Microsoft 365 was only available to a select few, those who were able to purchase 300 licenses. That however all changed on january 15th, when Microsoft announced that Copilot for Microsoft 365 would be available to all to purchase, at the same time they announced Copilot Pro, a Copilot for the consumer market, brining that amazing Chat-GPT integration in the office apps to everyone for just USD$20/month - which if you’re already paying for OpenAI, is a steal, if not, well yeah, it might be a bit steep, but I don’t see a future where knowledge workers don’t have access to something like this. I’ve got a license myself, and while I’m not impressed with it’s performance, part of that seems to be related to the fact at most of the information on our production tenant is in Danish, which Copilot doesn’t yet support. Read more Custom fonts on the roadmap All the way back at M365 conference last year, we were promised the possibility to “bring your own fonts” to SharePoint, and now it’s finally on the roadmap, and it’s coming in March 2024, how awesome is that! Read more WordPad retirement After 30 years of service, the WordPad app has been removed from the latest Windows 11 Insider builds, and will probably be removed from the final release as well at some point, but honestly with the latest updates to Notepad, I don’t think anyone will miss it, tabs and dark mode was all I ever wanted! Read more Changes to the Website tab in the new Teams client This is the first time I’ll ask all you IT-admins to open your message center, find a message, and down-vote it mc708500 is one of those announcements that really sits wrong with me, a change is coming to teams, when you press a webpage tab in Teams today, it opens inside the Teams app, however from april an forward on that will no longer be the case, and it’ll instead open in a browser. I get why Microsoft does this, as loads of websites don’t properly authenticate in the teams tab, but honestly it just kinda “throws ofF” the integration, and the feeling of teams being the “one stop app”. Read more Teams meeting enhancements Teams meetings is one of those things we’ve all been spending a whole lot more time doing in recent years - so any update that improves the experience is a welcome one, and this month we got a few of those. First of a change in the grid view, where the grid now shows everyone as equals, even if they don’t have a camera on, making it so that those without a camera on are no longer second class citizens. There’s also some minor changes to the invite email to improve usability of those, and lastly there’s a new waaaay more streamlined way to change which audio device you’re using, which is a welcome change, as the old way was a bit cumbersome, and often hard to explain to someone on the other end of a call who’s not that tech savvy. Read more Teams hide general channel This is a very simple but very welcome change to teams, simply the option to hide the general channel in a team, something I’ve had people ask me for literally since I started working in this space! Read more Power Apps User Defined Functions When doing “pro-code” programming we’ll often talk about functions, and the awesome reusability of them, this is a change that’ll let you write your own functions in Power Apps, and reuse them across your app, which is a huge step forward in the “low-code” space, and something I’m really looking forward to trying out! - anything that minimizes code duplication is a win in my book! Read more Microsoft Lists - Drag to order list items This one unfortunately doesn’t have a ton of information on what or how it’ll work, but from the sound of it, it’s a very welcome feature, and something people have been requesting since the dawn of time! - the option to drag an item in a SharePoint list and reorganize the list, how awesome is that! Read more TL;DR Microsoft is finally releasing the beast with Copilot for Microsoft 365, and it’s availability to everyone, but also keeps at it for improving all the other amazing products in the platform, and I’m really looking forward to what 2024 has in store for us, not just in terms of AI, but also in terms of the other products in the platform, and how they’ll evolve. --- ### Use the URL to navigate the user Forfatter: Alexander Henkel Dato: 2024-01-28 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-280124-sharepoint-use-the-url-to-navigate-the-user/ Learn how to do some URL tricks to navigate the user in SharePoint Did you know that you can change the way the user is interacting with the SharePoint list by adding specific strings to the URL? There is a well written article that you can read from Microsoft here but I will give you some examples as well Examples Let’s say you would like to user to be able to add a new item to a list, but it should be done without the user having to navigate to the list item and clicking +New. I usually add a “New item” button on the front page, using the Quick links webpart When you go to your list your URL will look like this https://tenant.sharepoint.com/sites/JSONformatting/Lists/JSON%20formatting/AllItems.aspx Notice before reading further When you are working with the links, and it is not working as expected, try opening a new browser tab, since some caching might interfere! NewForm You can change AllItems.aspx to NewForm.aspx instead https://tenant.sharepoint.com/sites/JSONformatting/Lists/JSON%20formatting/NewForm.aspx This will take the user directly to a “NewForm”. Notice also that the form is now taking all of the available space, instead of being a sidepanel. Full page If you have the left side navigation, we can actually also remove that, to take the user to a “full page” view https://tenant.sharepoint.com/sites/JSONformatting/Lists/JSON%20formatting/NewForm.aspx?env=Embedded Navigate When the user is clicking save, they will be navigated back to the list, but we can also control what is happening after the using is clicking Save (or Cancel) https://tenant.sharepoint.com/sites/JSONformatting/Lists/JSON%20formatting/NewForm.aspx?Source=https://tenant/sites/JSONformatting/&env=Embedded Note that we added a ?Source and changed ?env=Embedded to &env=Embedded and moved it to the end of the URL to work probably. In my case here the user is being send back to the frontpage. Microsoft Lists We can also take the user directly to the “Microsoft List” environment if we like to https://tenant.sharepoint.com/sites/JSONformatting/Lists/JSON%20formatting/NewForm.aspx?env=WebViewList Edit in full screen button With this knowledge we can create a button that takes the user to a full page edit experience. I like to make this button for lists with many options { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "align-items": "center", "margin-top": "2px", "margin-bottom": "2px" }, "children": [ { "elmType": "a", "attributes": { "href": "=@currentWeb + '/Lists/YOURPAGENAME/EditForm.aspx?ID=' + [$ID] + '&Source=' + @currentWeb + '/Lists/YOURPAGENAME&env=Embedded'", "_comment": "DispForm EditForm NewForm", "target": "_blank", "class": "ms-bgColor-themePrimary ms-fontWeight-bold ms-bgColor-themeDarker--hover", "data-interception": "off" }, "style": { "height": "30px", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Edit", "class": "ms-fontSize-16 ms-fontWeight-regular", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "8px", "margin-left": "10px" } }, { "elmType": "span", "style": { "margin-right": "10px" }, "txtContent": "Edit in full screen" } ] } ] } In this code we are using @currentWeb to get the current tenant name, but we still have to insert the page name. Then we are using the EditForm to send to user to an EditForm page, and we are embedding the form in a full width. You can also use DispForm if you only want to show the form without the user being able to edit it. We can also use the URL to create filters on lists, which has many use cases, but we will save that for another post ‎ --- ### Change the layout of the multi-choice column Forfatter: Alexander Henkel Dato: 2024-01-26 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-260124-formatting-change-layout-multi-choice-column/ Learn how to change the layout of a multi-choice column in SharePoint The multi-choice field can look a little tivoli if you have many options, and the overflow does, in my opinion, not look very good 👇 We have the possibility to remove the style, but I actually also like that the user can distinguish between the choices. Here is the standard formatting without colors 👇 Change the layout with column formatting We are going to change the layout to look like this 👇 { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "display": "flex", "flex-direction": "column", "align-items": "start", "margin": "3px", "overflow": "hidden" }, "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "div", "forEach": "choice in @currentField", "style": { "margin": "5px", "overflow": "hidden", "text-overflow": "ellipsis", "white-space": "nowrap", "max-width": "100%", "padding": "5px", "border-radius": "5px", "background-color": "#eaeaea", "box-sizing": "border-box" }, "children": [ { "elmType": "span", "txtContent": "[$choice]" } ] } ] } The multi choice column in SharePoint is a bit advanced to work with, but we are going trough each choice (forEach) of the fields in @currentField, and then we take the property [$choice] and show it in the column The width of the elements are all different, so you could change “max-width” to “width”: “200px” or whatever size fits, to make all of the choices have the same width. Depending on which choices you have, that can also look pretty. Play around with the “padding” also. You can change all of the sides (and up and down) by writing “padding”:“5px 10px 10px 10px” or something similar You can also change the “nowrap” to “wrap” do display the entire text Another example We can also change the look to behave a bit more like a tag (or behaving a bit more like the standard formatting) { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "children": [ { "elmType": "div", "children": [ { "forEach": "choice in @currentField", "elmType": "div", "style": { "padding": "5px", "border-radius": "5px", "background-color": "#eaeaea", "display": "inline-block", "margin": "5px" }, "children": [ { "elmType": "span", "txtContent": "='# ' + [$choice]" } ] } ] } ] } You can play around a little with the “style” of the code, just look up “css” and “overflow” or “display”, but I think these two examples covers many use cases Single choice If you would like to apply the same type of formatting to a single choice field, use this code instead { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "style": { "display": "flex", "flex-direction": "column", "align-items": "start", "margin": "3px", "overflow": "hidden" }, "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "div", "style": { "margin": "5px 0px", "overflow": "hidden", "text-overflow": "ellipsis", "white-space": "nowrap", "max-width": "100%", "padding": "5px", "border-radius": "5px", "background-color": "#eaeaea", "box-sizing": "border-box", "display": "=if(Number(@currentField) == 0, 'none', 'inline')" }, "children": [ { "elmType": "span", "txtContent": "@currentField" } ] } ] } With this code you can now prettify your SharePoint list for the multi choice or single choice columns ‎ --- ### Start a flow from a button and change the site panel Forfatter: Alexander Henkel Dato: 2024-01-22 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-220124-formatting-start-flow-button/ Learn how to add a button to a SharePoint list that will start a flow Let’s say you want the user to start a flow on a specific list item. Maybe they should start and approval flow, create a copy of the list item, or something similar. What I think is the best user experience, is to add a button to the list item. When clicking the button, a side panel will appear. Here we can control the header, and the text of the button that will start the flow. How to Create a new Text column Format the column and add this code { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/column-formatting.schema.json", "elmType": "div", "customRowAction": { "action": "executeFlow", "actionParams": "{\"id\":\"FLOWID\", \"headerText\":\"Send for approval\",\"runFlowButtonText\":\"Go Go Go\"}" }, "style": { "display": "flex", "align-items": "center", "margin-top": "2px", "margin-bottom": "2px" }, "children": [ { "elmType": "a", "attributes": { "class": "ms-bgColor-themePrimary ms-bgColor-themeDarker--hover" }, "style": { "cursor": "pointer", "height": "30px", "text-decoration": "none", "display": "flex", "align-items": "center", "text-align": "center", "justify-content": "center", "margin": "5px", "color": "#fff", "border-radius": "3px" }, "children": [ { "elmType": "span", "attributes": { "iconName": "Flow", "class": "ms-fontSize-16 ms-fontWeight-regular", "title": "Details" }, "style": { "color": "#fff", "flex": "none", "margin-right": "5px", "margin-left": "8px" } }, { "elmType": "span", "style": { "margin-right": "8px" }, "txtContent": "Start Flow", "attributes": { "class": "ms-fontWeight-bold" } } ] } ] } To get the FLOWID, go to Power Automate and open your flow (you don’t need to select edit) In the URL you can find the ID, see the picture below In the code, you can change the headline (Send for approval) or the text of the button, starting the flow (Go Go Go) Start Flow will be the text on your list item, and Flow is the icon - you can find more icons here Run only users When you are creating a flow button like this, your users needs access to run the flow. In Power Automate there is an option called Run only users Select the edit button, and add the users (or groups) that will have permission to run the flow. If you have not added any users here, the button will only work for yourself (since you are the owner of the flow). If no users is added to the “Run only users”, nothing will happen when the button is clicked. You can now add an action to you items that is easy accessable for your users Note that the trigger for your Power Automate should be For a selected item or For a selected file ‎ --- ### Simple error handling and scopes Forfatter: Alexander Henkel Dato: 2024-01-20 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-200124-powerautomate-simple-error-handling-and-scopes/ Learn about scopes in Power Automate, and how to create simple error handling I had been working with Power Automate for about 3 months, when I was introduced to an action called scope. Now I use scope for almost any flow that I create! A scope as an action can’t really do anything, but still, it can do a lot of magic. The scope action is functioning as a container, where you can insert your actions. Here are 3 reasons view you should use scopes 1. Better overview of your flow The picture below is simplified, but the idea is, that you insert actions inside a scope, and name that scope something more explainable. This will give you a much better overview of your flow, and you can collapse the part of the flow that you are not working on. When you load the flow, all scopes are collapsed by default, so you can quickly navigate to the part you want to work on. 2. Copy all actions inside a scope If you need, lets say the same 5 actions in different parts of your flow, simply copy the entire scope instead of copying each action individually. As pr. January 2024, this is not yet supported in the new designer, but can still be done in the old designer. 3. Error handling There are many ways you can do error handling in your flows, but this is the most simple one. Insert all your actions inside a scope (name it “main”). Outside your scope, as you last action, insert an send an email action In the settings of you send email action panel, unclick Is successful and select the other 3 options. Setup the e-mail action with the subject “Power Automate xyz has failed” In the body of your e-mail insert the below code as “dynamic content”, this will create a link for you directly to the flow that has failed. concat('https://flow.microsoft.com/manage/environments/', workflow()?['tags']['environmentName'], '/flows/', workflow()?['name'], '/runs/', workflow()?['run']['name']) I think it is important, that if you are using flows in a production environment, you get notified if anything stops the flow from running as expected. Here is my entire flow You will now get a notification if any of your production flows have failed. The next thing you could do it to write the error into a SharePoint list, so that the administrators can easely have an overview of failed flows ‎ --- ### Remove the link from a lookup field Forfatter: Alexander Henkel Dato: 2024-01-18 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-180124-formatting-remove-the-link-from-a-lookup-field/ Learn how to remove the link from a lookup field If you are using a lookup field in SharePoint, the selected value will be a link to that value. This is not always a good experience for the end-user 😣 An example could be that you are using a lookup field for the user to select a country. Your column would then look like this with a blue clickable link 👇 If the user clicks the link - in my example here - the user will be directed to the item, that could look like this 👇 Not a very pretty experience for the user, since my country list is only used as “back-end” data. In most of my views I always end up “disabling” the lookup field as a link. How to To remove the link, we can apply JSON formatting to the column. Select the column, choose Column settings -> Format this column In the panel that appears on the right, select Advanced mode Here you can insert the following code { "$schema": "https://developer.microsoft.com/json-schemas/sp/column-formatting.schema.json", "elmType": "span", "txtContent": "@currentField.lookupValue" } Short explanation of the code txtContent will show the selected value, when we use @currentField We could add a link in the JSON code, change the color, add or button or almost anything we desire, but in this case we use the JSON to remove everything, and just show the value. When you are using @currentField on a lookup field it is important that you add the .lookupValue, otherwise you will see the value as [object Object] In most other cases you should only use @currentField Now our lookup field will look like this, and is unclickable 👇👌 Additional reading - what is a lookup field? A lookup field is a column that allows you to connect a list to another list on your site, and display information from the source list on the target list. A lookup field can also show additional data from the specific list (ex. the country code) When you are working with Power Apps a lookup field can also be very powerful when collection data. Unfortunately a lookup field can not be linked between different sites, which would have been a huge benefit 🙆‍♂️ Recommended sources Article about lookup fields from Microsoft Article about lookup fields from SharePoint Maven ‎ --- ### Parking Space Booking App Forfatter: Alexander Henkel Dato: 2024-01-16 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-160124-powerapps-parking-space-booking-app/ Learn how to create a fully functional app in this step by step guide In this exercise, our goal is to develop a Power App, allowing employees within a company to reserve parking spaces for specific dates. The implementation will consider the following rules There are four available parking spaces. Each individual can only reserve one parking space. The app should display a list of individuals who have booked a parking space. Users should have the capability to delete (only) their own bookings. For the purpose of this exercise, we will mainly focus on the technical solution. SharePoint Let’s initiate the process by setting up a new SharePoint list to serve as our database. We only need to create 2 columns Create a new list and name it Parking Column names BookedBy (Type: Person): This column will record the individual responsible for the booking. BookedDate (Type: Date): This column will store the date for which the parking space is reserved. It’s important to note that the internal name of the SharePoint column will be based on the initial name you provide. I advice to avoid spaces or non-English letters in the column internal names. Although SharePoint allows renaming columns later, the internal name remains fixed once set. You can already now create a few items in the list. Power Apps Now that our database is prepared let’s create a new canvas app. Go to make.powerapps.com and select Create and Blank app When prompted about what type of app, select Blank app Name the app Book parking space and select Phone under format and click Create Add a data source To include your previously created Parking SharePoint list as a data source, navigate to the Data section in the left-hand panel and select Add data Choose the site were you created your list and select the Parking list and then click on Connect The SharePoint list has been added as a data source Yeeeehaa! We’ve successfully set up an app with a connection to our data source. Now, we can dive into saving and displaying data from this source! As mentioned earlier we won’t spend much time on the design aspect. While it’s generally a good practice to rename your elements during app creation, let’s skip that for now—ain’t nobody got time for that while learning. Let’s proceed by adding the following elements to the app. Icon Cars Label Book parking space Label Select date Date Picker Button Book parking Label Spots available Vertical gallery (don’t select a source yet) Press F5 and see your app in action. At this point, the only functionality we have is the ability to modify the date. Clicking the button Book parking doesn’t trigger any specific actions, and our gallery has no data. Press F5 again to exit the preview of the app. Vertical gallery If you have not already added items in your SharePoint list, go back and add a couple of items. Once done, navigate to Data in PowerApps and, under the three dots, choose Refresh Now, let’s add some data to our gallery. Select the Items property for the Vertical Gallery and write Parking Power Apps is trying to help us, and automatically inserts some details from our SharePoint list. There are just a few issues. It only displays the name. There’s no image. It doesn’t consider the selected date. In many cases you can use the Blank vertical gallery and start by building your own design. Time to dive into some code Our goal is to customize our vertical gallery to display only items corresponding to the selected date in our DatePicker. In simple terms, we want to filter our Parking SharePoint list based on the BookedDate column, which we have selected in our DatePicker Filter(Parking, BookedDate = DatePicker1.SelectedDate) Run you app (F5) and see how the data is changing when you are changing the date in your DatePicker Next, let’s make some changes to the gallery layout. First, add a headline named Booked by and insert a Trashcan icon. For the user picture, we want to utilize the Office 365 user photo. To access this data source, go to Data and include Office 365 Users as a source. Select the Image and use the following code in the Image property: Office365Users.UserPhoto(ThisItem.BookedBy.Email) This way, you can display the photo of the user who booked the parking space. However, we’ve encountered a problem. When selecting a date with no bookings we are getting a big red nasty error that there is no user photo available. Also, if the user doesn’t have a photo, a white square is displayed Dealing with user fields in PowerApps/SharePoint can be challenging Head to Google, find a suitable No User photo, and upload it under media (I named my photo “NoUser”). Now, add the following code to the Image property If( IfError( Office365Users.UserPhotoMetadata(ThisItem.BookedBy.Email).HasPhoto = true,"" ), IfError( Office365Users.UserPhoto(ThisItem.BookedBy.Email),"" ), NoUser ) This code includes an if statement. If the user has a photo, it will be displayed, otherwise, the NoUser photo will be shown. Additionally, we added an IfError function to “hide” the error message. If the user has no photo, the function returns nothing (""). The part that hides the error message is IfError(Office365Users.UserPhoto(ThisItem.BookedBy.Email),""), We will revisit the gallery later to work with the Trashcan enabling users to remove their own bookings. Book parking button Now, let’s focus on the Book parking button. We are going to work with the Patch function within the OnSelect property. The Patch function can be complex, but fortunately Microsoft has provided a helpful you can find here In our case, the function is a bit complex since we’re dealing with patching a user field. In simpler terms, we want to send the selected date from our DatePicker to the Parking SharePoint list. We want to send the details of the user who is currently logged in Add this code to the OnSelect property of the button Patch( Parking, Defaults(Parking), { BookedDate: DatePicker1.SelectedDate, BookedBy: { Claims: User().Email, DisplayName: "", Email: "", Picture: "", Department: "", JobTitle: "" } } ); A few things to note Refering to the Microsoft article, we are using Defaults because we’re creating a new record, not modifying an existing one. The selected date from the DatePicker is patched into the BookedDate field BookedDate: DatePicker1.SelectedDate) For the BookedBy column, we patch the user’s email using User().Email Power Apps / SharePoint expects additional information like DisplayName, etc., but we don’t control that information. Therefore we add some blank values and rely on SharePoint/Microsoft 365 to manage the rest. Try to remove ex. Email: "" from the code and see the error you get in your code. Run your app and click the Book parking button to test the functionality. So far so good, but it looks like we still have a few things we need to take care of The user can’t remove their own bookings The user can book more than 1 parking space The user can remove other peoples bookings The users can book all the parking spaces they want (we only have 4 available) Let’s begin with the trashcan. It’s easier when testing, that we can add/remove a record within the app. When I first started with PowerApps it took me forever to figure out how to remove an item from a gallery, but it’s quite straightforward, I don’t think it requires any additional explanation Add this code to the trashcan icon for the OnSelect option Remove(Parking, Gallery1.Selected) To ensure that the users can only delete their own records, we will modify the DisplayMode option of the trashcan. In natural language we want to make the trashcan selectable (edit) if the BookedBy.Email is the same as the current user “User().Email”, otherwise it should be disabled (or hidden, if preferred) Add this code to the DisplayMode property of the button If( ThisItem.BookedBy.Email = User().Email, DisplayMode.Edit, DisplayMode.Disabled ) X parking spaces available Now we must track number of booked parking spaces, since we only have 4 available. We want to create a variable Set(variableName) that holds the number of booked parking spaces, since we want to display the number in our label “Spaces available: X”. For the Book parking button add the following code below the Patch code. Notice that we have ended the code with a ; this means that we can now add additional code that runs after the patch. Set(varNumberOfBookings, CountRows( Filter(Parking, BookedDate=DatePicker1.SelectedDate) ) ) I always prefix my variable names with var since I struggle to remember names. When I need to use a variable I start typing var and Power apps list all the variables I have created Note that we get a delegation warning (indicated by yellow lines) suggesting that code might not work on large datasets. It would be more suitable to create a Collection but we will save that for another time. However we could write something like the following: ClearCollect( FilteredBookings, Filter( Parking, BookedDate = DatePicker1.SelectedDate ) ); Set( varNumberOfBookings, CountRows(FilteredBookings) ); In the code, we set the variable varNumberOfBookings to count the number of rows returned from our Parking SharePoint list. The rows returned are filtered based on the selected date in our DatePicker Now we can go back to our label Spaces available and insert the variable: "Spaces available: " & 4 -varNumberOfBookings In this case, we want to show a negative number, so we substract 4 from our variable. But it is not only when the user clicks the ‘Book parking’ button we want to update the number of rows. It should also be done when: The app is loaded (App: OnStart) (You can run the “OnStart” command by right clicking App) The user removes a booking (Trashcan - OnSelect) The user changes the date (DatePicker1 - OnChange) Insert the code in the 3 above functions Run you app and change the date, add or remove a booking and see how the Spaces available changes. Maximum 4 bookings When a user interacts with our app, we now always update how many spaces are available. To avoid more than 4 bookings on a specific date, we want to disable our “Book parking” button. The button should also be disabled in case the user has already booked a parking space at the same date. If( varNumberOfBookings >= 4 || CountRows( Filter( Parking, BookedBy.DisplayName = User().FullName, BookedDate = DatePicker1.SelectedDate ) ) >= 1 || DatePicker1.SelectedDate < Today(), DisplayMode.Disabled, DisplayMode.Edit ) We are constructing an if statement that specifies if our variable is greater than or equal to 4 or (||) if the logged-in user is included in the rows returned, then the Book Parking button should be disabled. While our app might not be production ready, and needs a design makeover, we now have a functional app Here are some ideas how you could improve the app and challenge yourself Design it the way you like it - Maybe use the new modern components? Remove the delegation warnings Disable the book parking button if the user selects a date before Today() Add a tooltip or a label explaining the user why the button is disabled ‎ --- ### Highlighted Content Webpart Forfatter: Alexander Henkel Dato: 2024-01-14 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-140124-sharepoint-highlighted-content-show-files-in-folder/ Learn how to show the content from a folder in the highlighted content webpart The standard SharePoint webpart highlighted content can show specific files from your site, using standard filtering. But what if you want to show files from another site. To achieve this you can use the custom query and write path:"https://tenant.sharepoint.com/sites/yoursitename/Shared Documents Replace tenant and yoursitename, and if you are not using the standard document library, change the Shared Documents with the name of your library. If you have a sub-folder inside your documents library, we can see the folder now, but what if you would like to show the content of that folder (the files inside that folder) The obvious thing to do would be to just add the full path to that folder path:"https://tenant.sharepoint.com/sites/yoursitename/Shared Documents/Folder name But the result we get will look like this We are only seeing the folders - not the content of the folders as your might expect. The solution We need to use a different type of query to get the value of what is inside the sub-folder DocumentLink:"https://tenant.sharepoint.com/sites/yoursitename/Shared Documents/Test Folder/*" AND FileType:* The AND FileType:* is used to remove all items that is not a file type. You can leave it out if you want. Now you know how to show the content of your folders, using the highlighted content standard webpart ‎ --- ### Check if an item is new or edited Forfatter: Alexander Henkel Dato: 2024-01-12 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-120124-powerautomate-check-if-an-item-is-new-or-edited/ Learn how to see if an item has been created or modified, and create different automations based on that value In some cases we want to perform different automations depending on if an item has been created (new) or has been modified (edited) To do this we can use the action “Get changes for an item or a file” In my example below we will send an e-mail with a specific text if the item is new, and another e-mail if the item has been edited As your first input action insert When an item is created or modified Next, insert the action Get changes for an item or a file (properties only) Id = ID from your previous action. Since = Trigger Window Start Token from your previous action ‎ This action returns which properties have been editied, and is returning it as true of false value (it does not return the actual value, like “old” value and “new” value) Looking at the “raw output” of the Get changes… action we can see that we get the following output "body": { "SinceVersionExisted": true, "SinceVersionId": 512, "SinceVersionLabel": "1.0", "UntilVersionIsCurrent": true, "UntilVersionId": 1024, "UntilVersionLabel": "2.0", "ColumnHasChanged": { "ID": false, "Title": true, "OData__x0031_23": true, "calculated": false, "OData__ColorTag": false, "ComplianceAssetId": false, "Modified": true, "Created": false, "Author": false, "Editor": false } } From the code above, we can see that the SinceVersionExisted = true and the Title = true which means that the item has been edited, and the Title field has been edited Condition Insert a new action from the Control library called Condition If you go back to the code/output above, you can see it returns an modified value, but unfortunately this property always returns true. So we can’t use that option. If you search for the SinceVersionExisted in your dynamic content we can’t find it since it is outside the ColumnHasChanged To work with this option, we need to write our own expression: outputs('Get_changes_for_an_item_or_a_file_(properties_only)')?['body/SinceVersionExisted'] A short explanation of the code With outputs we are telling Power Automate to get the outputs from the action Get_changes_for_an_item_or_a_file_(properties_only) (note that spaces are replaced with _ ) Then we add a ? which means that IF an output exist, it should return from body the value of SinceVersionExisted (true or false) Now that we know if an item is new (false) or edited (true) we can create two different e-mails like below 👇 Now you now how to check if an item is new or edited. This will also prevent your flows from going into an infinite loop ‎ --- ### Changing the command bar in SharePoint Forfatter: Alexander Henkel Dato: 2024-01-10 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-100124-formatting-changing-the-command-bar-in-sharepoint/ Learn how to change the command bar in a SharePoint list with JSON view formatting I like to change the command bar for my SharePoint lists or libraries. Especially the +New button, and change the text to something more describable Here is the standard look We do have the possibility to Rename the buttons Reorder the buttons Hide the buttons ‎ To change the view go to your view on the top right corner and select Format current view Let’s begin by changing the text for your +New button and use another icon { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/row-formatting.schema.json", "commandBarProps": { "commands": [ { "key": "new", "hide": false, "text": "New recipe", "position": "0", "iconName": "testbeakersolid" } ] } } The code explained Each key has a “name” and you can find all of them here What we are doing here is telling that the key “new” should not be hidden, the text should be “New recipe”, the position should be the first (0) and the icon should be testbeakersolid Now we can continue and start hiding some of the buttons we don’t want. Let’s hide the Edit in grid view" button { "$schema": "https://developer.microsoft.com/json-schemas/sp/v2/row-formatting.schema.json", "commandBarProps": { "commands": [ { "key": "new", "hide": false, "text": "New recipe", "position": "0", "iconName": "testbeakersolid" }, { "key": "editInGridView", "hide": "true" } ] } } We just added some properties to the edit in grid view button. Try change the text of the “delete” button, and move it around. ‎ Overflow Instead of hiding the buttons, we can also push them to the three dots (the overflow), this can be done by adding the property “sectionType”: “Overflow” { "key": "editInGridView", "hide": "false", "sectionType": "Overflow" } Now the key is not completely hidden, but can be accessed trough the overflow menu Multiselection Another thing we can do is to change the text of the button if one item is selected, or if multiple items are selected. { "key": "edit", "selectionModes": [ "MultiSelection" ], "text": "Edit multiple items" } Here we are changing the text of the edit button from Edit to Edit multiple items if more than 1 item is selected You can read more examples in the Microsoft article here If you have not worked with JSON formatting before, I think this is a pretty straight forward solution, and a good beginner exercise Here is an example of hiding all the buttons in your list, expect for the new button { "commandBarProps": { "commands": [ { "key": "new", "hide": false, "text": "New list item", "position": "0", "iconName": "testbeakersolid" }, { "key": "editInGridView", "hide": true }, { "key": "comment", "hide": true }, { "key": "integrate", "hide": true }, { "key": "automate", "hide": true }, { "key": "share", "hide": true }, { "key": "export", "hide": true }, { "key": "alertMe", "hide": true }, { "key": "managealerts", "hide": true } ] } } Now you can change the name and icon of the “New” item, and also hide some of the buttons in the menu bar (however note that the options will stille be available if the user right click on the item) ‎ --- ### Change the header when creating a new list item Forfatter: Alexander Henkel Dato: 2024-01-08 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-080124-formatting-header-list-item/ Learn how to change the header of “new item” with 2 examples The standard header when creating a new list item, is in my opinion, not very user friendly, and as a standard just says “New item” Luckely we can change that with JSON, and I will give you two examples Example 1 ‎ ‎ Go to your list and select +New, and in the upper right corner select the edit icon and choose Configure layout You will see that you have to possibility to apply formatting to Header, Body and Footer. In this blog we will focus on the Header only. Insert the following code { "elmType": "div", "attributes": { "class": "ms-borderColor-neutralTertiary" }, "style": { "width": "99%", "border-top-width": "0px", "border-bottom-width": "1px", "border-left-width": "0px", "border-right-width": "0px", "border-style": "solid", "margin-bottom": "16px" }, "children": [ { "elmType": "div", "style": { "display": "flex", "box-sizing": "border-box", "align-items": "center" }, "children": [ { "elmType": "div", "attributes": { "iconName": "=if([$Title] == '', 'addtable', 'Table')", "class": "ms-fontSize-42 ms-fontWeight-regular ms-fontColor-themePrimary", "title": "Details" }, "style": { "flex": "none", "padding": "0px", "padding-left": "0px", "height": "36px" } } ] }, { "elmType": "div", "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontWeight-bold ms-fontSize-24" }, "style": { "box-sizing": "border-box", "width": "100%", "text-align": "left", "padding": "21px 12px 12px", "overflow": "hidden" }, "children": [ { "elmType": "div", "txtContent": "=if([$Title] == '', 'New list item (header formatting)', 'Editing ' + '[$Title]')" } ] } ] } The code explained "iconName": "=if([$Title] == '', 'addtable', 'Table')" Here we are looking at the Title column. If it is empty, we will show the addtable icon, and if it is not, we will show the Table icon. If you are not using the Title column as your required column, change $Title with the internal name of your column. "txtContent": "=if([$Title] == '', 'New list item (header formatting)', 'Editing ' + '[$Title]')" Here we are again looking at the Title field, and if it is blank we will show the header “New list item (header formatting). If it is not blank we will show Editing and the value of the Title column This is what the header looks like when editing the item Example 2 ‎ This approach is a little more bold. Here is the code { "elmType": "div", "attributes": { "class": "ms-bgColor-themePrimary ms-fontColor-white" }, "style": { "width": "100%", "height": "100px", "box-sizing": "border-box", "display": "flex", "align-items": "center", "justify-content": "center", "margin-bottom": "16px" }, "children": [ { "elmType": "div", "attributes": { "class": "ms-fontSize-24 ms-fontWeight-bold" }, "style": { "box-sizing": "border-box", "text-align": "center", "overflow": "hidden", "padding": "12px" }, "txtContent": "=if([$Title] == '', 'New list item', 'Edit list item ' + '[$JustText]')" } ] } Here is what it looks like now, when editing the list item That’s it, you can now with little effort change the look and feel of the list item in your future lists ‎ ‎ --- ### Add an icon to your SharePoint column Forfatter: Alexander Henkel Dato: 2024-01-03 URL: https://pinksky.dk/blog/forfatter/alexander-henkel/2024-01-030124-formatting-add-an-icon-to-your-sharepoint-column/ Learn how to prettify your SharePoint column with an icon I think that out of the box, the SharePoint list is very boring and not very user friendly to look at. I like to apply a lot JSON/column formatting to my columns. In this post we are going to learn about a simple column formatting, where we can add an icon to our column / text. We are going to focus on the formatting marked in red below, but I choose to show you a bit more of my list. See how diverse the list looks, compared to a standard SharePoint list. The code { "$schema": "https://developer.microsoft.com/json-schemas/sp/column-formatting.schema.json", "elmType": "div", "style": { "background-color": "transparent" }, "attributes": { "class": "ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m" }, "children": [ { "elmType": "span", "attributes": { "iconName": "CityNext2", "class": "ms-font-l" }, "style": { "padding-right": "6px", "display": "=if(Number(@currentField) == 0, 'none', 'inline')" } }, { "elmType": "span", "txtContent": "@currentField" } ] } To change the icon you must change CityNext2, you can find icons and their internal names here A short explanation of the code Font color ms-fontColor-neutralSecondary ms-fontColor-neutralDark--hover ms-font-m Here we are using the theme colors. Try to change ex. neutralSeconday with some of the colors listed below themePrimary themeLighterAlt themeLighter themeLight themeTertiary themeSecondary themeDarkAlt themeDark themeDarker neutralLighterAlt neutralLighter neutralLight neutralQuaternaryAlt neutralQuaternary neutralTertiaryAlt neutralTertiary neutralSecondary neutralPrimaryAlt neutralPrimary neutralDark blackwhite Icon size "class": "ms-font-l" Here you can control the size of the icon. You can try to change the l to and M or XL etc. Hide icon "display": "=if(Number(@currentField) == 0, 'none', 'inline')" With the display property, we are making sure that if the user has not inserted anything into the column, we are not displaying the icon. Note, if you are using the column formatting on a lookupField, you need to change the txtContent from @currentField to *@currentField.lookupValue Community You can find many other column formatting examples from the PNP community, right here 👇 PNP List-Formatting @ GitHub ‏ --- ### December in M365 Forfatter: Dan Toft Dato: 2023-12-22 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-12-december-m365/ The holidays are here, and 2023 is coming to and end, and what a year it’s been! - I had a blast finishing off the year at ESPC in Amsterdam at the end of november, we’ll touch on some of the stuff from there in this post, but mostly we’ll focus on what’s going on in the M365 space headed into 2024. - Let’s get started! SharePoint embedded Repository services (RAAS), SharePoint/Microsoft Syntex Repository services, SharePoint Premium Repository services, or as it’s now called SharePoint Embedded, was announced to have entered public preview at ESPC. Now if you’re scratching your head, and none of those names ring a bell, you’re not alone, this is a product that I’ve been highly excited about for the past year, but it’s not something that’ll change the world for end users. Instead the focus here very much is on the dev side of the table, essentially what this is, is SharePoint without a user interface, so we as devs can get all the benefits of SharePoints file handling, from Co-authoring, Versioning, Permissions, Retention policies, and so on, but without any UI, so we can essentially build an app that looks just the way we want it to. This is brilliant if you’re an ISV, building a solution that stores files, as you’ll now be able to store those files in your customers tenants, and not have to worry about who’s storing the data. Read more OneDrive web app offline availability For years now we’ve had the OneDrive sync client, that worked “fine”, but it always had the limitations of using explorer as it’s primary way of working with content. Now with Microsofts push towards making OneDrive it’s “file explorer” for the web, the option to install the OneDrive Web app as an “offline” app just makes sense. Read more Graph CLI v 1.0.0 If you’ve tried to call Graph from a command-line tool before, I’m sure you’ve run into either having to type the full url yourself, or only being able to call a small subset of endpoints, well no more, Microsoft has released a new CLI tool “Microsoft Graph CLI v1.0.0” - with the depreciation of more and more legacy APIs this is a very welcome change! - it’s just easier to test out new APIs via a CLI tool, than it is to write a full app only to realize that you can’t get the date they way you thought. I’m also interested in seeing some of the automations that I’m sure the community will come up with, using this! Read more WebDav deprecation in windows I’m sure that title means nothing to 95% of you, but it might still be worth paying attention, if you’re accessing SharePoint in your file explorer, and you’re not using the OneDrive sync client, there’s a good chance you’re using WebDav, it’s the tech behind the good old “Open in Explorer” button in SharePoint. Now as of windows 11 23H2 this feature is going to be default disabled, and I’m guessing we’ll see it being completely gone within a few short years, it’s time to move to OneDrive sync! Read more First party E-signatures It’s been announced a while back that SharePoint Premium (Microsoft/SharePoint Syntex) would be getting DocuSign and Adobe sign integrations, which in and off it self was awesome, however what Microsoft recently announced is that they’ll be building a first party eSignature solution, while it’ll still be a Pay-As-You-Go service, having every thing first party is pretty neat! - and it should be available to US customers already, with the rest of the world following in 2024. I kno I’m looking forward to trying this out! Read more Deprecation of SharePoint add-ins This is something that will inevitably affect a lot of enterprises, and I know loads of people will be very frustrated with it. Microsoft has announced the retirement of the SharePoint Add-in model - now I would say that no one should’ve been building any add-ins in the past 5 years, and I’m pretty sure there are people at Microsoft who would agree with me on that statement. However there are still a lot of add-ins out there, some might’ve been built in house years and years ago, others from ISVs who’re no longer around, now is the time to start planning how you’re going to be migrating off of them. The timeline for this is as follows: March 1st, 2024 - No new add-ins can be submitted to the store July 1st, 2024 - No new add-ins can be installed from the store November 1st, 2024 - Any new tenants created after this day will not have the ability to install add-ins April 2nd, 2026 - All add-ins will stop working. My only concern with this, which I’ve been fairly public about is the removal of Remote event Receivers (ReR), currently this is the only way to block list item creation at an API level, and it’s the only way to know instantly when an item has been created, Webhooks are the only alternative, but they’re not instant, they do have better error handling, so that’s a pros and cons situation. Also there’s some features that can still only be done with ACS, such as updating certain aspects of user profiles … hopefully we’ll see Graph APIs, or the deprecation of user profiles before 2026. Read more Updates to the authoring experience in SharePoint All the way back in spring at M365 conf we saw a ton of cool announcements for what the future of SharePoint is going to look like, and now we’re starting to see some of those features roll out. Some of the features that’ve started to roll out are the advanced image editor tool, and the new page templates, the ability to send news posts as an email. And very soon we’ll start to see the Co-authoring experience, and Copilot in SharePoint as well! - and what I’m perhaps looking forward to the most is the brand center, and the overhaul of SharePoint page layouts as a whole, that would be coming very soon! Read more Teams app test tool If you’ve ever built a teams app, you’ll know that there’s a high amount of friction to get started, and to debug it, Microsoft has heard our cries, and has released a new tool to help us out! The Teams app test tool is essentially a local version of teams, that you can use to test your apps, and debug them, it’s still fresh off the press, but it’s already looking very promising, I love the idea of being able to test my apps locally, and being able to test them without having permissions to install them in my tenant. Now I’m just looking forward to the Teams Toolkit integration with this! Read more Message extensions in Outlook goes GA A while back MS announced a preview of a feature that would let you bring your teams message extensions into Outlook for things like link unfurling, that has now gone GA, and is available for everyone to use! The idea being that when you paste a specific type of link you can render some custom UI, the same way that when you link someone to this blog post a preview of the post shows up, except with message extensions, these can be way more dynamic. Read more Clipchamp premium A while back Microsoft announced that we would have Clipchamp in M365, we’ve started to see that show up here and there, for instance in SharePoint document libraries, now Microsoft has pulled a classic Microsoft move, and announced that there will be a premium tier. The things that’re going to be “locked down” are the ability to export videos in 4k, “brand kit” features that allows your company to create a “kit” of colors, fonts, and so on to use, some premium stock content, and some advanced analytics with stream. So most people should be more than good with the free tier! Initially the premium SKU will be priced at USD $5/month, but will increase to USD $7 after June 30th 2024. (as add-on to M365 licenses) Honestly it seems reasonable enough to me, I’m sure the 4k export takes a lot of processing power on the back end, and stock content can get expensive. It’ll also be available without Microsoft 365, the full prices are as follows: Intro Price Regular Price Clipchamp Premium (add-on to Microsoft 365) $5 $7 Clipchamp Standard (standalone) $3 $3 Clipchamp Premium (standalone) $7 $10 Read more SharePoint API Resource Specific Consent This is a small thing, but something that’s been a long time coming, and something that I’m very happy to see! In SharePoint it used to be that our Permission Scope what Sites.ReadWrite.All or Sites.Read.All, which meant that you would’ve access to the entire tenant, now about a year ago I wrote a blog posts on the Sites.Selected permission scope, which allowed you to scope down to a specific site, but it still gave you access to all the content on that site. Now we’re going to be seeing even more granular permissions, which is something client are more and more focused on, and it’s awesome to see Microsoft moving in that direction. Read more Archive teams channels This is a feature that just makes sense! - how many times have you been in a team, had a channel that was no longer relevant? - but you didn’t want to delete it, because you might need some of the info in there later, well starting in January you’ll be able to archive channels. I’ve not yet seen any info on what’s going to happen to things like the SharePoint folder that’s created for the channel, but I’m guessing it won’t be moved to a read-only state, so if you know your way around you might still be able to make edits. Read more Viva Connections call APIs from built in designer For a very long time now, the default Viva Connections Adaptive card designer has been very limited, in that it couldn’t access any data, just whatever JSON data you put in there, well that’s about to change! With the updated designer you’ll be able to make call not only to SharePoint, but also Microsoft Graph, this means you’ll be able to fetch any data in your tenant! - unfortunately it’s still not possible to make several chained calls, but it’s a start! - maybe we’ll see the option to do that in the future, perhaps using Power Automate to fetch our data, and then calling that flow from the designer. This is honestly something I had been wanting to make an SPFx sample for eventually, but never got around to, so I’m very happy to see Microsoft taking the default Adaptive card designer to the next level! Note: you’ll need to enable this with a setting on tenant level using PowerShell Set-SPOTenant -IsDataAccessInCardDesignerEnabled $true Read more Delve Retirement If you’ve been around in this space for longer than I have, you might remember when delve was introduced, and the headaches it caused for a lot of people, who had no clue what governance was, and thought that if they just hid content it would be all good. Well on december 16th 2024, Delve will be retired, I’m honestly split on this one, Delve is a great concept, but with the people card in M365 it’s not really needed anymore, and it’s not like it’s been getting any love from Microsoft in the past few years. So I think I’m happy to see it go, I would rather see people move towards tools like Viva Engage for that “this is me” experience - especially with the new “personal timelines”. Read more Changes to Power Query ATTENTION ADMINS This is a important change, or well sorta, the important part is really just that you keep all your machines updated, but it’s still worth mentioning, because I know that there’s loads of people out there who might be running an old script or excel file on an old machine out there. But starting March 11th, 2024, you’ll need to ensure that your machines that use Power Query have “Microsoft Edge WebView2 framework” installed, and that all your data sources support at least TLS 1.2. Read more GPT-4 Turbo with Vision is now available on Azure OpenAI Service The GPT-4 Turbo model with vision has now been released on Azure OpenAI service, this is a very powerful model, that we can use to do things such as OCR, and image captioning, price wise it’ll be more expensive than the GPT-3 model, but it’s also a lot more powerful. Unfortunately it’s still locked down behind the preview wall, and you’ll need to request access to it, but it’s still very cool to see this being available on Azure! Read more TL;DR It feels like Microsoft is preparing for the age of AI by deprecating a lot of old tech, and I’m digging it, while it’s highly frustrating to see things like ReRs go away, it’s also good to know that that’ll lead to more resources available internally for future development of new features. I’m personally really looking forward to what 2024 has to offer, and I’m sure we’ll see loads of cool AI stuff coming! That’ll be the last blog post of the year from me, so happy holidays and a great new year to all of you out there, and I’ll see you in 2024! 🎉 --- ### November in M365 Forfatter: Dan Toft Dato: 2023-11-19 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-11-november-m365/ This months post is a bit early, as I’m headed off to ESPC for the last week of november, and I know for a fact I won’t have time to write it then, but loads of stuff happened in November, especially around ignite - so let’s get started. Show declined meetings in Outlook This one is one of those things that has been frustrating to me ever since I became an adult and started to use calendars, why is it that when I decline a meeting I have no way to “re-find” that event, sure I can look in my deleted messages, but realistically I’m not going to do that. When this is particularly awesome is, when you have that meeting where you declined, because maybe you had a dentist appointment, it’s now 8am on the day, your dentists office calls, your dentist is sick, now being able to see all the meetings you had declined, and perhaps even still join them is awesome! Read more Graph - The end of OneDrive and SharePoint search endpoints Microsoft 365 Search has been all the rage for the past few years, creating confusion for devs all over the world, which endpoint should I choose? - SharePoint or Microsoft search? Well Microsoft has made that choice way easier, by announcing that the SharePoint and OneDrive search endpoints will no longer be receiving updates, instead you should use the Microsoft 365 Graph search endpoint. Read more Custom user properties on persona cards For a few years now we’ve been able to update our persona cards, and add custom properties, but always only using the Graph Beta endpoint, well now it’s finally GA! You can use the OnPremisesExtensionAttributes to add custom properties to your persona cards. I’ve been having some issues doing it from SPFx due to the token audience - but it works great from the graph explorer. Read more Changes to workspace retention settings in Fabric and Power BI BEWARE - when you delete a workspace in Fabric or PowerBi, the current default retention is 30 days, in a upcoming update Microsoft will allow admins to change this to anywhere between 7 and 90 days. However, they’re setting the default to 7 for all tenants! Read more Updates to User Profile Photos in Microsoft 365 User Profile Photos have always been a mystery to people (I even wrote a blog about it a while back) Microsoft has finally decided to ‘Fix the fundamentals’, this includes creating a single imaging service, instead of the current scenario, where SharePoint, Exchange, and teams all have their own. The goal is for new photos to be synced everywhere within 24 hours, currently it can take up to 30 days for teams to get updated. See more New teams is coming to the web We’ve been seeing more and more of the “new teams” client recently, and now it’s coming to the web as well, how awesome is that Read more Teams chat in Outlook web Not a ton of context is given in this roadmap item, but from the sounds of it, you’ll be able to chat with your teams contacts from Outlook web, this is awesome, the more I can stay in context the better Read more Archive teams channels Currently you have the ability to hide channels, but no way to lock them down, this is changing, you’ll be able to archive channels, which will make them read only, and hide them from the channel list, this goes great with the upcoming M365 archive feature. Read more Add individual file shortcuts in OneDrive For a while now we’ve had the option to add shortcuts to folders, or favorite files, this is just another way to create a “link” to a file that you’ll be able to store in your OneDrive - having more options is always a good thing, now you’ll be able to move these shortcuts around, and organize them in folders, sorted in a way that makes sense to you. Read more New Documents library templates In recent time we’ve seen Microsoft do a huge push for templates, now we’re getting templates for document libraries, this is awesome, and will make it easier for users to understand the options that SharePoint provides. But beware, as Kasper Larsen called out on X, it’s not all roses, the templates do not utilize Site Columns, but rather list columns, making it harder to reuse the content types, and get them to play nicely in search. Read more Rich notifications for Outlook As you might’ve heard, the Outlook REST api has been marked for deprecation, in favour for Graph, this of course means we need more and more features to be available in Graph, and now we’re getting rich notifications, what this essentially means is that you’ll be able to get more information with the notifications, where’s with the “traditional” notifications you’d get a very minimal amount of information, and then have to fetch the rest yourself. Read more Teams - Auto update to new Teams A few months ago, we learned that Microsoft would GA the new teams client, and that they would eventually update all clients to the latests version, well now we have a date March 31st 2024 is the date! Read more Changes to Project for the web license requirements I have a bit of a hard time telling entirely what is in this roadmap item, but it seems like Microsoft is changing the licensing requirements for Project for the web, and allowing users with a M365 license to do some changes in Project, for instance marking tasks as completed, this is MEGA, I’ve worked with several clients where the license for project was the deal breaker, cause they wanted everyone to be able to do some changes. Read more The new Microsoft Planner The above change is awesome, but didn’t make much sense to me, but however in the light of other news it makes a ton more sense, Microsoft is merging all their ‘simple’ task management tools, and creating a single app, ‘Planner’ - which will now support the anything from your personal planner boards all the way up to project for the web projects, where you need a gantt chart, dependencies, and all that good stuff. - how neat is that! Read more Microsoft Fabric goes GA Fabric which is Microsofts data analysis tools package, announced back at build, it’s not a tool that I’m too familiar with, but if you’re a PowerBi consultant it’s for sure something I would take a look at Read more Mesh release date Mesh, Microsofts take on the metaverse, that was the next big thing about a year ago, just before Chat-GPT launched and then promptly we all forgot what mesh was, it’s now finally releasing, virtual 3D rooms coming to teams, with a GA date of January 2024, finally - I’m honestly looking forward to playing with this, now sure I fully see the business case just yet, but maybe I’ll be sold after trying it! Read more SharePoint Premium Okay, so this seems like a bigger announcement then it is, SharePoint Premium is essentially just a new name for the current Syntex, that being said there are some really cool promises for the future, like the ability to write a description of what you want in a column, and just have Syntex parse the document and write whatever you asked for, a single teams app for “high-value” documents, and give you warnings before contracts expire, some impressive promises for better governance tools, and much more. But for now I’m holding my breath, and waiting to see what the future brings. Read more Copilot studio Power Virtual Agents are getting Copilot power, and a nice rebrand to Copilot studio, now with a focus on the ability for users to build their own “Copilots” - and using connectors integrate with 3rd party services, for instance if you want to build a integration to your HR system and make vacation requests, you’ll train a Copilot to do that using the Copilot studio. Read more TL;DR As always, Microsoft is really pushing the boundaries of what’s possible, and I’m really excited to see what the future brings, and I’m looking forward to playing with Mesh, and the new SharePoint Premium features. And how freaking awesome will the new planner app be! --- ### October in M365 Forfatter: Dan Toft Dato: 2023-10-27 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-10-october-m365/ OneDrive 3.0 Microsoft started the month off with their OneDrive event where Jeff Teper and the gang essentially summarized a lot of the recent changes we’ve seen announced to OneDrive, the main focus was the overall facelift that we’ll be seeing in our OneDrive homepages, remember, OneDrive is your file explorer on the web 🙌 One really big feature they announced that I’m super stoked about is cross tenant OneDrive migrations! - We didn’t get a ton of details, but it’s coming, which is awesome, and unfortunately yet another nail in the coffin for migration tools Read more OneDrive sync improvements As I’ve previously addressed I absolutely hate the way the OneDrive sync client handles deletions, the number of times I’ve seen accidental deletions from users who wanted to unsync a folder and ended up deleting thousands of files, now Microsoft isn’t strictly speaking solving the issue, they’re however changing the warning that appears, and making it very clear to the users what they’re doing, it’s a step in the right direction 🙌 The really big thing here for me tho is the open in app feature, this is a feature that we’ve wanted … Well forever, when I started working with SharePoint 5 years ago the most requested feature on the feedback portal (uservocie) was the ability to open PDFs in the client (Adobe acrobat), earlier this year it was announced that we would soon be able to do this, now we’ve finally gotten some more details on when/what we can expect from this, it should be releasing in December, and will allow us to open not just PDFs, but ANY file type, even CAD files, this is for me a gamechanger! Read more Clipchamp for work A while back we saw the announcement that Clipchamp would be coming to M365 for work, now we’re seeing this rolling out, really looking forward to seeing an even better integration, I’m personally already using Clipchamp for any video work I need to do, so getting it even more integrated into the M365 ecosystem is very welcome. Read more App bar customization If you’re like me you never quite got the point of the left hand side app bar that launched in Microsoft 365 and SPO a few years back, well it looks like Microsoft has found a good use case for it, they’ll now allow users to customize it, unfortunately, any changes your admin has already rolled out will be overridden, and admins will no longer be able to edit the users choices, so that’s a thing, but I’m all for it being the users “task bar on the web” Read more Preferred solution for Power Platform This is a very tiny, but super duper mega awesome change, you’ll now be able to set a preferred solution where your Power Platform customizations will go by default, this is a very welcome change, being able to have your flows always go in a solution just seems like something we should’ve had the option to do from day one Read more Town Halls in Microsoft Teams Teams live events for me never really took off, especially in comparison to other tools on the market, it seems Microsoft has picked up on this too, and decided it was time for a refresh This includes a lot of neat stuff, up to 20.000 attendees and 50 concurrent streams at once, the ability to have a green room where the presenters can prepare and have a chat before the producer moves them “on set” - it really looks like Microsoft wants to be competitive in the live event space as well! Read more Graph activity activity log If you’ve ever been using Microsoft Graph, and pushed it just a tad hard you’ll no doubt have run into throttling, and not understood what’s using your resources, maybe it seems like you’re getting throttled no matter what, or maybe you’re just curious what the apps on your tenant are accessing - now we finally have a way, the Graph activity log that’s rolling out right now will let you query a log of the requests that have hit your tenant recently, how neat is this! My only wish is that they’ll start to incorporate APIs like SharePoint into a similar style log This is btw rolling out now 🙌 Read more New Power Automate editor For the first time in forever Power Automate got a serious facelift, not only including a more modern (and logic apps like) editor, featuring less pop-ups , and for me most importantly, a multiline expression editor 🙌 Read more Loop Components Coming to Microsoft Teams Channels Finally! - I’ve been asking since the release of loop why loop and teams didn’t work better together, it’s genuinely been a mystery to me that there never was a way to get it to work together. Now we’re if nothing else seeing a step in that direction, soon we’ll be able to use loop components in a teams channel, with the loop file being stored in the teams channels SharePoint site. Read more Viva updates We’ll be seeing a bunch of improvements when it comes to Viva in the coming time, Microsoft has a great not too marketingy blog post summarizing all these changes, but that doesn’t mean I won’t pick a few favorites For Viva connections, we can look forward to: Dark mode Improved analytics New cards, including Sway, Forms and Power Apps, a People Search Card and with last months release of SPFx 1.18.0 we saw a ton of improvements to our development options when it comes to customizing the adaptive cards A big one for me is improvements to the Card designer that’ll let you pull line of business data from Graph directly into your cards without any custom code! - this is a big deal as many ACEs are simply wrappers for Graph data, so being able to do this out of the box without any custom code is awesome. Viva engage Viva connections also gets some new changes, including a new post type “Article”, similar to when writing articles on Linkedin, this will give you slightly more formatting and image options then a regular post. We’ll also be seeing some better analytics, including analytics on how people are networking, reacting, and from where they’re accessing engage. Draft and schedule posts, another nice feature that we’re seeing, is the option to schedule posts, something loads of people will appreciate for sure! - I know of several organizations that’ll be very pleased with this! Viva goals The long awaited Copilot for viva goals has finally GA’ed and is ready to help you setup your OKRs, even faster and smoother! Viva Amplify Finally after a long wait Viva Amplify goes GA, Amplify is one of the Viva products that I’ve been looking forward to the most, the ability for internal communication to spread their news across all the different products, but still manage it all at a single point is awesome! - not only will communicators be able to create sleek looking news for both Outlook and SharePoint but also see all their analytics in one place, and even more advanced statistics then we’re used to, including stuff like how many from each department sees their news. Viva Home This one is really small, but maybe oddly the most surprising one for me on this whole list. Microsoft launched a “home” page for Viva, that’s not that surprising, but at the same time they’ll be bringing all of the Viva apps to the web, which is surprising to me, given that loads of the viva branding has been around Viva being apps in teams previously, but it makes sense to branch them out and improve the availability I guess. This also means you’ll be able to pin Viva apps in your “Waffle menu” Read the entire blog post TL;DR Microsoft is definitely gearing up for the Copilot launch in november, but that does not mean we’re not seeing a bunch of other cool stuff, I’m especially excited about the new Power Automate editor, and the ability to open files in the client from OneDrive, but there’s a lot of other cool stuff as well! As always, the platform keeps evolving and giving you even more value for your money, and I’m excited to see what’s next! --- ### Simplify your SPFx imports Forfatter: Dan Toft Dato: 2023-10-23 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-10-simplify-spfx-imports/ The thought Having worked in SPFx for long enough, I’m sure you’ve seen imports like this: import { IMyInterface } from '../../../../../../common/models/IMyInterface'; And thought, “there must be a better way!”, looking at library imports, you’ll see that they’re much cleaner: import { SPFI } from "@pnp/sp"; Well, there’s a way to turn your imports into something similar, and it’s not that hard! // Let's turn this: import { IMyInterface } from '../../../../../../common/models/IMyInterface'; // into this: import { IMyInterface } from '@Models/IMyInterface'; The solution It’s actually quite simple, we’ll only need to update two files (three if you’re using fast-serve). gulpfile.js Firstly we need to update out webpack file, now normally this would be done in the webpack.config.js file, however SPFx does webpack a bit different, instead they allow us to modify parts of the webpack config during the build process, this is done in the gulpfile.js file. Add the followind lines of code, beware of the path, we’re replacing src with lib as that’s where the files will be after the build process. gulpfile.js const path = require('path'); ... build.configureWebpack.mergeConfig({ additionalConfiguration: (generatedConfiguration) => { if (!generatedConfiguration.resolve.alias) generatedConfiguration.resolve.alias = {}; generatedConfiguration.resolve.alias['@Models'] = path.resolve(__dirname, './lib/@common/models/') return generatedConfiguration; } }); tsconfig.json Next we’re going to update the tsconfig.json file, this is where we’ll tell TypeScript that we’ve updated the paths, and where to find them, so that we don’t get errors while coding. Note that we’ll be using the src part here, and setting the base url to .. tsconfig.json ... "compilerOptions": { "baseUrl": ".", "paths": { "@Models/*": [ "src/@common/models/*" ] }, ... webpack.extend.js (Fast Serve) Lastly, if you’re using fast-serve, you’ll need to update the webpack.extend.js file in your fast-serve folder, and add the following lines: webpack.extend.js const path = require('path'); ... const webpackConfig = { resolve: { alias: { '@Models': path.resolve(__dirname, '..', 'src/@common/models/') } } } Now common for all of these things we just did is that you can add as many as you want! - I sometimes find myself ending up with the following list of aliases: @Common @Models @Enums @Utils @Hooks @Contexts And that’s just for inspiration, you can keep going! - please let me know what you come up with! TL;DR You can drastically improve the readability of your SPFx imports by setting up what are called aliases, and thereby shortening your import statements, and “obfuscating” away the folder structure. --- ### September in M365 Forfatter: Dan Toft Dato: 2023-09-27 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-09-september-m365/ Copilot release date! 🙌🙌🙌 So, I guess the biggest news this month is that Microsoft has finally announced a release date for Copilot! November 1st - MARK YOUR CALENDARS 📅 Other then a release date, Microsoft also announced some different things to create some ‘hype’ including Bing Chat Enterprise getting image generation support, a rename of the announced “Business chat” to “Microsoft 365 chat” Read waay more here SharePoint news in Outlook All the way back in early spring, at the M365 conference Microsoft announced on a feature that would allow us to send SharePoint news pages as emails, and now it’s finally here! While a little less “complete” then I had personally hoped, I think Microsoft solved it perfectly “respectable” - instead of every single web part needing to be adapted to work in Outlook, they instead created a new page template that is optimized for Outlook. In this there are a few default templates (notice the letter icon) - what makes these templates special is that they have a quite limited set of web parts, but these web parts are optimized to also work in an email format. Read more Changes to teams licenses in EU So I guess it was a matter of time before the EU would take a look at, perhaps the most successful Microsoft product in the last decade, Teams. The result ended up being rather annoying … from October 1st teams will no longer be included in your “normal” Microsoft 365 license, instead you will need to purchase a separate license for Teams in the EEA and Switzerland. At the same time this will cause a price increase for all Microsoft 365 licenses in the EEA and Switzerland - or sort of, the price will be dropped by €2/month, but the additional Teams license will cost €5/month, so in the end it’s a €3/month increase for the same feature set. Luckily, this will not affect existing customers, so if you’re already using Teams, and have an active subscription, you will not be affected by this change (yet). Read more SPFx 1.18 The SharePoint Framework has been updated to version 1.18, while this one is more interesting for devs, I still want to mention it here as there are some interesting “behind the scenes updates”, a major one is the update to Node 18, a very welcome upgrade as node 16 went EOL just a few days ago. Other then that we got some awesome updates to adaptive card extensions, that’ll allow us to create some really cool experiences in Viva Connections. - brining more value to the users, with fewer click. New Outlook client to be included with Windows 11 This one is near and dear to my heart! For the past 7ish years since I got my first job as a first level IT supporter, I’ve hated Outlook with every fiber of my body, it’s slow, it’s clunky, it keeps crashing. However, the web experience with Outlook online, has ALWAYS been awesome, it’s fast, it’s responsive, it’s easy to use. Over the past year or so, Microsoft has brought this into an app, solving the biggest issue with the web experience, the lack of multi account support. I’ve been using the preview for probably a year, and I absolutely love it! Well it’s now officially releasing out of preview, starting september 21. it will start to roll out with Windows 11 updates, and be default pinned to the taskbar. I don’t agree with the pinned to the taskbar part, but I’m still super excited for this! - from a personal perspective, that is. I do feel sorry for the IT admins that will have to deal with the support calls from users that are used to the old Outlook client, and now have to learn a new one - just the nightmare when they moved the navigation to the side of the screen. 😔 Read more Planner component in Microsoft Loop If you’re not already using Microsoft Loop, what are you doing? - it’s awesome! Well it just became even more awesome, now there’s an option coming that’ll allow you to add a planner component to your loop page, allowing you to easily keep track of your tasks, and see the progress of your project in a more visual way then just a list of tasks. Read more Viva Connections mobile updates Microsoft is still going full steam ahead with Viva, and this month we got some updates to the mobile experience. The main focus in on improving the experience on mobile, not only simplifying the UI, but also bringing the existing SharePoint Home site closer to the user, this is probably in recognition of the fact that many orgs have already made huge investments into their intranet, and Viva Connections isn’t quite there yet in terms of providing the same options. Canvas built-in offline support This one is SUPER interesting! - going into public preview right now is an feature to enable offline support for canvas apps. This means that your device will locally sync data from dataverse while you’re connected to the internet, and allow you to make changes while offline, that’ll then sync back to dataverse once you’re back online. Read more Seamless collaboration in Microsoft Teams for multi-tenant organizations If you’re working in a multi-tenant environment, you’ve probably experienced the pains of multi geo teams! With this update, if your admin configures the multi-tenant organization (MTO) setup, and the new Teams App. You’ll now be able to chat cross tenant, and more importantly, have notifications sync across tenants, so you’ll no longer have to switch between tenants to see if you have any new messages It also enables a richer meeting experience, allowing things like Whiteboard, together despite the meeting not being in their “home” tenant. Read more Personal Planner plans Another tiny feature that appeared on the roadmap this month that’s a game changer for me is the ability to create personal plans in planner. Previously the only way to do this would be to create a private M365 group and add a planner to that, but now you’ll be able to create a plan that’s just for you, and not have to worry about the all the other sprawl that comes with a group. Especially for people that are in a tenant where group creation is disabled this is awesome! - whether or not you’ll later be able to share this plan with others is still unknown, but I’m almost hoping Microsoft doesn’t add that feature, but rather a way to “migrate” a plan to a group, if you need to share it with others at a later time. So that’s one more tool for personal task management, and while I as a geek appreciate this, I’m not sure if it’s the right move for Microsoft, we end up giving users decision paralysis, because there are soo many different options to do the same thing. Read more TL;DR Copilot release date: November 1st SharePoint news in Outlook Changes to teams licenses in EU Other then that, Microsoft keeps going full steam ahead with Viva, and the rest of their product suite, and shows no signs of slowing down anytime soon! --- ### Using React & Fluent UI in SPFx command set extensions Forfatter: Dan Toft Dato: 2023-08-30 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-08-react-fluent-ui-spfx-command-set-extensions/ Why? When you’re building SPFx command set extensions, you might’ve noticed that you don’t get a ton of UI options out of the box, just a plain dialog that can show some text, and a few buttons, this is fine for some scenarios, but if you want to build something a bit more advanced, you’ll no doubt have wanted to use React and Fluent UI, that’s what this guide is all about! Getting started When you’ve created your SPFx project, you’ll want to add the following packages a few packages, but their versions are important, so make sure you’re using the correct ones! We’ll need these packages: react react-dom @types/react @types/react-dom all in a matching version - the easiest way to determine which version you need is the SPFx Compatibility Matrix, check the react version for your SPFx version, and use that version for all the packages above. So in my save I ran npm i react@17.0.1 react-dom@17.0.1 --save-exact npm i @types/react@17.0.1 @types/react-dom@17.0.1 --save-dev --save-exact New I want to use Fluent UI, determining the version you need here is slightly more complicated, but if you’re on one of the (as of writing this) recent SPFx versions (1.16.0 and above ish) version 7 is the go to, this will change with SPFx 1.18.0 where we’ll be moving to version 8! at that time replace the @7 with @8 in the command below. npm i @fluentui/react@7 --save-exact Using React & Fluent UI Now that we’ve setup all our dependencies, we can start building with them! - unlike when building a Web part we’ll need to rely on the dialog framework provided by Microsoft, this means you’ll be rendering your content inside a dialog, and not overtaking the entire page. I wrote a ’neat’ component that’s reusable and allows you to render any React component inside a dialog (that’s extending the basedialog), this is what it looks like: DialogReactRenderer.tsx import * as React from 'react'; import * as ReactDOM from 'react-dom'; import { BaseDialog } from '@microsoft/sp-dialog'; export default class DialogReactRenderer extends BaseDialog { private element: React.FunctionComponentElement; constructor(element: React.FunctionComponentElement) { super({ isBlocking: false }); this.element = element; } public render(): void { ReactDOM.render(this.element, this.domElement); } protected onAfterClose(): void { super.onAfterClose(); ReactDOM.unmountComponentAtNode(this.domElement); } } Now in your onExecute method you can render any component you want, for example, here I’m rendering a component called MainComp: public onExecute(event: IListViewCommandSetExecuteEventParameters): void { switch (event.itemId) { case 'COMMAND_1': const component = React.createElement(MainComp); const wrapper = new DialogReactRenderer(component); wrapper.show().catch(er => alert(er)); break; } } the MainComp component just has to be a functional React component, mine just looks like this for now: import * as React from 'react'; import { PrimaryButton } from '@fluentui/react'; export interface IMainCompProps { } export const MainComp: React.FunctionComponent = (props: React.PropsWithChildren) => { const [count, setCount] = React.useState(0); return ( <>
setCount(count + 1)}>Click me ({count})
); }; See the full sample here. TL;DR Microsoft doesn’t ship a UI library with command set extensions, so unless you have other SPFx projects in your solution that’re using React & Fluent UI, you’ll have to add them yourself, this guide shows you how to do that, and how to render a React component inside a dialog. --- ### August in M365 Forfatter: Dan Toft Dato: 2023-08-24 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-08-august-m365/ Run a cloud flow for more than 30 days per invocation This is a small change that has a big impact, the number of times I’ve seen builder stumble into this unfathomably annoying limitation is crazy, traditionally when you’ve created Power Automate flows you’ve been limited to 30 days run time per invocation, this sounds like a lot, but if you’re doing a multi stage approval flow this often isn’t enough, especially around holiday periods, where people might be out of office for 2-3 weeks at a time. Read more Browse by meeting in OneDrive The OneDrive homepage has been cited to be “your file explorer on the web”, and Microsoft is taking that statement to the next level with this new update, you’ll now be able to browse your files by which meeting they where in, last month we saw how we where able to access files by people, and now we can do the same by meeting, having several different ways to access the files depending on how you best remember is great! Read more Meet Along with the above update to OneDrive, Microsoft has also been hard at work on a new update to teams, the “Meet” app, this app, allows you to get an overview of recent and upcoming meetings, access, chat, files, and if you’re a teams-premuium user, you’ll also be able to access AI powered overviews, mentions and suggested tasks. Read more New SharePoint site creation experience This one is IMO pretty minor for existing users of SharePoint online, but a major change for those who’re just getting started with SharePoint, Microsoft is bringing some updates to the site creation process in SharePoint Online - they’ll be adding new site templates, and updating some of the existing templates to provide more ootb value. See Here for more on what exactly is changing. Microsoft Clipchamp for work If you’ve had to edit a video in the past year or so, you might’ve come across Clipchamp, for me it’s like a modern era windows movie maker, just easy enough to use for me to get the job done, not advanced enough to do absolutely everything. I use it every time I need to edit a video, now it’s coming as part of your Microsoft 365 plan (notably not Office 365 licenses), this will allow you to integrate with your M365 data, and post your video with Steam, keeping all your data inside your tenant! Read more OneDrive sync client error handling changes If you’ve used the OneDrive sync client and been offline for any period of time while working, you’ll undoubtedly have seen the “Resolve sync file conflicts”, and if you’re anything like my clients, you’ll have pressed the “keep both” option, and later wondered, where the heck did my changes go? … or someone else will when someone else inevitably chooses the wrong version. Well no more! - Microsoft is removing the “keep both” option, and will now force you to merge the two files right away, I’m worried this will cause frustrations with the end user, or worse, cause them to overwrite others data, but I’m hopeful that this will be a good change in the long run, once people know how to use it. Read more about this change. New teams release date If you’re like me, you’ve been running the new teams client for a little while now, but having two experiences is always annoying for en users, something that’s come to my attention is that we now have a “switchover” date, for the new teams client - this is the date that Microsoft will start rolling out the new client as the default to everyone! - that data is Early October 2023. See all the different release dates here SharePoint Platform: Minor Visual Refresh If you’ve been around within web design for more then a decade, you’ve no doubt seen the trend that is sharp corners to soft corners to sharp corners - well lo’ and behold, we’re moving back to soft corners, Microsoft is updating the look and feel of SharePoint to have softer corners, I hope this is just a small part of the the modernization to SharePoint that’s supposed to launch later this year, but I’m not holding my breath for that one. Read more Colored folders Another cool update that’s started to roll out is colored folders, back at M365 conference in the spring we where promised colored folders in OneDrive, but now that they’re here (targeted release), and also available in SharePoint! I was initially sceptical, but I think the feature is growing on me, I love the ability to quickly identify my folders, something I’m sure people will actually be missing in file explorer! Thanks to Ami for the screenshot Announcements in Viva Connections If you’ve ever had an intranet, I’m sure you’ve had the need for a “THE SERVERS ARE DOWN” announcement channel - Microsoft is bringing this to Viva connections, this means that when accessing your intranet in teams, or on mobile, we’ll have the option to show an announcement banner, and serve the users a notification, this is awesome! - unfortunately there are no news on how this will be surfaced in SharePoint where you might already have made investments in your intranet, and want a similar experience. Read more Page sharing A neat little feature is Microsoft making it easier to share the SharePoint page you’re currently on, and also thereby giving others a direct permissions, in case you didn’t know: SharePoint pages are also stored as a file in SharePoint, and you can manage permissions on a page by page basis, it’s nice to see Microsoft promote this option more! Read more Python in Excel Even as someone who’s not a python developer, I know that python is “the” data analysis language, and it’s super awesome to see Microsoft embrace this, and not just stick to their Office Scripts running their own TypeScript, and as with office scripts, this will be able to run in the cloud, making it accessible to everyone, everywhere! Having different ways to not only calculate data, but also the option to map it in different diagrams and a like is absolutely awesome! Read more TL;DR Huge improvements to Power Automate, OneDrive, Teams, SharePoint, and Viva Connections, generally it’s awesome to see Microsoft really picking up the steam here towards the end of summer, and I’m excited to see what’s next with the upcoming conferences in the fall, personally I’m looking forward to an awesome Nordic summer late september! --- ### Using themed icons in SPFx command bar extensions Forfatter: Dan Toft Dato: 2023-08-08 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-08-themed-icons-spfx-command-bar-extensions/ Introduction So you know how when you’re creating an SPFx command bar extension it’ll reference the “icons/request.png” file so you can create your own icons and add to your solution. Awesome, right? - well yeah, if you’re an ISV and want to use your logo, but if you’re building a solution that you might not want branded with your logo, but just want to blend in to the platform - then it’s not so awesome. In this post we’ll be taking a look at how to use Fluent UI icons as a logo in your command bar extension, including theme support! Getting the SVG icon Firstly we’ll need the SVG version of the icon we want to use, here I really like using Flicon by Chris Kent, simply find the icon you like (I’ll be using “FullHistory” in this example) under export you can download the SVG version of the icon. Next open the .SVG file in a text editor like NotePad++ and copy the contents, you should have something like this: Encoding the SVG icon Now paste that value here, it’s a tool that encodes SVGs so they can be used in SPFx extensions - the tool is built by Hugo Bernier Pasting the contents into the tool will instantly encode it, and allow you to then copy the encoded value, it should look something along the lines of this data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2048 2048'%3E%3Cpath d='M896 512h128v512H896V512zM512 768H0V256h128v274q67-123 163-221t212-166T752 37t272-37q141 0 272 36t245 103 207 160 160 208 103 245 37 272h-128q0-123-32-237t-90-214-141-182-181-140-214-91-238-32q-129 0-251 36T546 267 355 428 215 640h297v128zm512 384h1024v128H1024v-128zm0 384h1024v128H1024v-128zm0 384h1024v128H1024v-128zm-863-657q36 129 105 239t166 194 214 140 250 74v130q-154-21-292-83t-250-158-193-224-123-278l123-34z' fill='%23333333'%3E%3C/path%3E%3C/svg%3E This value can be used as the iconImageUrl in your command bar extension manifest, like this: "YOUR_COMMAND_NAME": { "title": { "default": "Command One" }, "type": "command", "iconImageUrl": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2048 2048'%3E%3Cpath d='M896 512h128v512H896V512zM512 768H0V256h128v274q67-123 163-221t212-166T752 37t272-37q141 0 272 36t245 103 207 160 160 208 103 245 37 272h-128q0-123-32-237t-90-214-141-182-181-140-214-91-238-32q-129 0-251 36T546 267 355 428 215 640h297v128zm512 384h1024v128H1024v-128zm0 384h1024v128H1024v-128zm0 384h1024v128H1024v-128zm-863-657q36 129 105 239t166 194 214 140 250 74v130q-154-21-292-83t-250-158-193-224-123-278l123-34z' fill='%23333333'%3E%3C/path%3E%3C/svg%3E" } Now you’ll have the icon in your command bar extension, but it’s not themed to the current site theme - so let’s fix that! Theme support First we’ll need fluentui if it’s not already installed in your solution, install the relevant version: npm i @fluentui/react@7 --save Next we’ll need to create a helper function to get the current theme color, this is a little tricky but it’s doable. I create the following file: themeHelper.ts and add the following code: import { ITheme, getTheme } from '@fluentui/react'; const ThemeState = (window).__themeState__; ​ // Get theme from global UI fabric state object if exists, if not fall back to using uifabric export function getThemeColor(slot: string) { if (ThemeState && ThemeState.theme && ThemeState.theme[slot]) { return ThemeState.theme[slot]; } const theme = getTheme(); return theme[slot as keyof ITheme]; } This will allow us to get the current theme color, now we need to use this in our command bar extension. In the onInit() method of the command bar extension we’ll need to add the following code: import { getThemeColor } from './themeHelper'; ... public onInit(): Promise { ... // Get your command const yourCommand: Command = this.tryGetCommand('YOUR_COMMAND_NAME'); // Get proper theme color const fillColor = getThemeColor("themeDarkAlt").replace('#', '%23'); // Generate the colored icon const exportSvg = `data:image/svg+xml,%3Csvg xmlns%3D'http%3A//www.w3.org/2000/svg' viewBox='0 0 2048 2048'%3E%3Cpath d='M896 512h128v512H896V512zM512 768H0V256h128v274q67-123 163-221t212-166T752 37t272-37q141 0 272 36t245 103 207 160 160 208 103 245 37 272h-128q0-123-32-237t-90-214-141-182-181-140-214-91-238-32q-129 0-251 36T546 267 355 428 215 640h297v128zm512 384h1024v128H1024v-128zm0 384h1024v128H1024v-128zm0 384h1024v128H1024v-128zm-863-657q36 129 105 239t166 194 214 140 250 74v130q-154-21-292-83t-250-158-193-224-123-278l123-34z' fill='${fillColor}'%3E%3C/path%3E%3C/svg%3E`; // Set the icon yourCommand.iconImageUrl = exportSvg; ... } The important part here happens in the “Generate the colored icon” notice how we’ve replaced the %23333333 in the fill property with our own generated color. Using template literals we can then set the color of our SVG, and then set the iconImageUrl of our command to the updated SVG - and there you go, your command bar extension now has a themed icon and will blend in with any SharePoint environment! How cool is that? TL;DR Get the SVG icon you want to use Encode the SVG icon Set the SVG as the iconImageUrl in your command bar extension manifest Replace the fill property of the SVG with the current theme color Profit! --- ### July in M365 Forfatter: Dan Toft Dato: 2023-07-29 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-07-july-in-m365/ Azure Active Directory is no more, long live Microsoft Entra ID This is an annoying one! - but I guess it makes sense. Microsoft is renaming Azure Active Directory to Microsoft Entra ID, I think I heard somewhere that AAD was referenced around 1.8 million times across the Microsoft Docs, so this is a massive undertaking for Microsoft. And I think the biggest problem for them will be all the community articles as well, I know I’ve written a few articles where I’ve referenced AAD, and I’m sure I’m not the only one. It does make sense from a branding perspective, as AAD never just was just your active directory in Azure. Microsoft 365 Archive This is another one of those “why hasn’t this been a thing before” things - Microsoft is finally going to be letting us archive SharePoint site, in many industries you have to store project data for upwards of 100 years, but you don’t actually need to the data day to day, this feature will let you put a SharePoint site into “cold storage”, and then at a later point you’ll be able to reactivate that site for a fee (the only sample price I could find was USD$ 1.34 for a 128 GB site) this is really cool for those clients that’re running low on storage, and still need to store information due to regulatory requirements. Microsoft 365 Backup Finally! - Built in Backup, right in M365, I’ll be honest, for a while I didn’t see the point in backing up your Microsoft 365 environment … that was until I had my first client who needed a file that was deleted 5 months ago, well - you’re out of luck. With this you get full backup of OneDrive, SharePoint and Exchange built right into M365 - I haven’t seen any pricing just yet, but it does look really cool, and backup is always going to be somewhat expensive (right until you need the backup) - having it built right into the platform is awesome, especially since Microsoft can probably restore files faster then any 3rd party ever would be able to due to throttling, with the rise of ransomware targeted at online services, this is absolutely awesome! It does also look like some of these features will be exposed to partners, which means Microsoft are aware that there are people who’ve already made a living doing just this, and they’re not trying to push those guys out of the market. Improvements for document library version limits Another pretty neat feature that was announced this month, with a rollout starting March 2024 is improvements to the number of versions SharePoint stores, or at least the option to improve it. Currently we can set a number of versions to keep, on a document library level, but that’s a bit course, this will bring some AIish’ness to the table, and let SharePoint decide how many versions to keep based on several different factors, including the number of users, the number of versions, the frequency of edits, and the size of the files. (See roadmap feature id 145802) Copilot pricing We finally got some pricing for Copilot! USD$30/month/user - my initial reaction was “that’s steep!” - but as I’ve sat here thinking about it for a bit I think it might actually not be unreasonable, Copilot is not for everyone, it’s for a select group of your users, those who might benefit from a secretary, or those who create content. Honestly I could see it being worth the $USD 30 for some employees to pay out of pocket, heck, if my employer won’t cover it, I might pay for it myself, at least if it’s as good as we’ve been promised. List performance increases Back in May we where promised performance increases for lists, and in July these started to roll out! - unfortunately there are some limitations to this, including it not working for lists where you have a custom form experience (either via SPFx or PowerApps), or at least, not yet, and it’s only available in the modern experience. Browse OneDrive by people Something I didn’t know I needed, but that just make sense from the second I saw it! - you can browse files by people! - this just makes sense, we’re humans, we tend to remember things by what people we worked with, maybe more so then what project we worked on, or what the file was called. Image source: https://twitter.com/SuperSimple365/status/1678652905984000001/photo/1 Viva pulse roll out started Viva pulse is supposed to be rolling out here in July, I haven’t yet had a chance to play with it, but I’m looking forward to it, I’m hoping it’ll be a good tool for managers to get a feel for how their employees are doing, and how they can improve their work environment. SPFx 1.18 July saw the first preview release of SPFx 1.18, 1.18 is what I would call a minor release, but we’re finally seeing one of the most requested features since the release of Adaptive card extensions! We’re getting the option to customize the cards, including the option to add buttons right on the front page of your connections dashboard! OneNote supports stream This one is minor, but OneNote finally got support for Stream on SharePoint videos, this is great, because it’s one of those features we’ve been missing in the new roll out of stream on SharePoint. Project Archimedes This one is one that I missed back in May, so I’ll bring it up here! - It’s a game changer in terms of creating useful governance reports, tools like Rencore, Avepoint, ShareGate and alike are probably a bit worried about what the future holds for them. A built in tool to generate data for governance reports, this is packaged under the syntex umbrella, so you’ll be paying for your usage, this is awesome since some of the other tools have a minimum license cost of 25 seats to even get started. This is a tool that’ll let you generate a sharing report right from within your Microsoft 365 environment, and allegedly it’s able to generate a full report of a tenant with 1.000.000+ documents in just a few hours, this is insane, as soo may of those other tools really struggle with that initial scan. From there you should also be able to generate delta reports, so you’re not scraping your entire environment every time. The output (to the best of my knowledge) is just a HUGE json file that you can then dump into Power BI and build a report that highlights only what your org asks to see! Bing Chat Enterprise Have you been playing with the Bing chat? - well now Microsoft is bringing Bing Chat Enterprise! - this is the same thing, but with a promise that no company data will ever leave the organization, this is unlike the private version og the Bing chat. Bing Chat is essentially a Copilot light, the main difference is in the fact that it does not have access to your data, you need to feed it data! Bing chat enterprise is included in select M365 licenses by default, this includes Microsoft 365 E3, E5, Business Standard, and Business Premium. otherwise it can be purchased for an additional price of $USD 5/month/user. TL;DR Microsoft really seems to have a focus on “what are out partners doing, and can we do that as well?” - which is both awesome and scary to see, they’re slowly eating up partner, both in governance, and now backup, we’re certainly moving towards a future where no data has to leave your M365 environment. Other then that, the focus on AI has not slowed down one bit, which is awesome to see! --- ### EF for SharePoint Forfatter: Jeppe Spanggaard Dato: 2023-07-23 URL: https://pinksky.dk/blog/forfatter/jeppe-spanggaard/2023-07-entity-framework-for-sharepoint/ Why and how? Wouldn’t it be great to have the ability to retrieve data from SharePoint in the same manner you are familiar with from Entity Framework? :face_with_monocle: At least that’s what I thought when I began working with SharePoint, having worked with EF for several years. I thought there must be a way to retrieve data from SharePoint without having to work with dictionaries and create custom parsers to convert it into class models. And then it hit me! Why not attempt to replicate the approach used by Entity Framework? EF is user-friendly, easily extensible, and does not require numerous custom parsers. So that’s why I’ve started a Proof of Concept (POC), where I aim to implement the functionality from EF. I have made my repository public, so everyone is welcome to provide input and contribute ideas. See GitHub repository How does it work? To make it work, you need to add a number of custom attributes to its class models. SPList SPField Example class [SPList(listGuid: "128b4e17-691a-4e28-9934-7bd399ba907a", listTitle: "EmployeeTasks")] internal class EmployeeTaskModel { [SPField(internalName: "ID")] public string? Id { get; set; } [SPField(internalName: "Title")] public string? Title { get; set; } [SPField(internalName: "EmployeeTask_Done")] public bool? Done { get; set; } [SPField(internalName: "EmployeeTask_Employeer")] public FieldLookupValue? Employeer { get; set; } } SPList - It should be thought of as similar to the Table-attribute (e.g [Table(“StudentMaster”)]) from EF, but it accepts both a GUID and a title. First, the code will attempt to find the list using the GUID, and if not found, it will try to find it using the title. SPField - It should be regarded as the Column-attribute from EF, and similarly, it accepts the internal name of the list field. This will be expanded significantly in the future. One of the ideas I have is to allow you to define whether it should be converted to an enum as well. How to use it To use this, it doesn’t take much effort as it is neatly packed away. Therefore, only a few methods are exposed/displayed. However, it must be noted that it is still only a POC, and therefore, not everything is thoroughly tested or handled for errors very gracefully. A prerequisite for using this is to have the PnP Core SDK installed, as all extensions, etc., are built on PnP’s web context and models. Get list items Normally, data would need to be extracted in the following way. var list = context.Web.Lists.GetById(new Guid(...), p => p.Title, p => p.Items, p => p.Fields); var listItems = list.Items.AsRequested(); Now, it is neatly packed away, so data is extracted in this manner. Unlike the previous method, this one parses the data to the specified class model. IEnumerable items = context.Web.GetItems(p => ...); Update a list item Updating a list item can be done incredibly easily and quickly. Just take a look at this method: internal EmployeeTaskModel UpdateEmployeeTask(EmployeeTaskModel employeeTask){ EmployeeTaskModel updatedTask = context.Web.UpdateItem(employeeTask); return updatedTask; } To summarize This POC was created to determine if it was feasible to extract data from SharePoint in an easy and efficient manner, similar to what we are accustomed to with EF. I will continue working on this POC with the hope that one day it will evolve beyond just a proof of concept. Perhaps, in the future, it could become a part of the PnP Core SDK? :star_struck: --- ### June in M365 Forfatter: Dan Toft Dato: 2023-06-28 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-06-june-in-m365/ Teams Two new types of teams backgrounds Microsoft has announced two new types of backgrounds for Teams, the first is a blurred background with the option to add a image for instance your company logo on top, this is pretty neat to give a “glass wall” effect! The second is an animated background, I’m not sure how I feel about the animated backgrounds, but that depends on the implementation, if it’s like on the news where it’s slowly moving it’ll be okay, but imagine being in a call with someone who has nyan cat as their background! Teams new client support for 3. party apps This is only a very minor mention, but we can now finally use your orgs personal teams apps in the new teams app, this is a small thing, but was actually the only thing holding me back from upgrading, super pleased with this one! Sequential Approvals Another feature that was supposed to launch (but I’ve yet to see) is sequential approvals, a feature that let’s you build a multi step process while starting an approval in the approvals app. I’m really looking forward to this feature, it’s something I often hear requested from customers, when demoing approvals. Viva Viva pulse goes public preview Viva pulse is Microsofts tool for managers to keep a finger on the pulse and get real world feedback from their employees, it’s now in public preview, it’s nice to see Microsoft starting to focus on the employee experience, and not just productivity. I have yet to implement any of the Viva modules for any of my customers (except connections), but I’m looking forward to it, and this combined with insights is going to be a great toolkit for managers to see how their employees are doing, and how they can improve their work environment. https://techcommunity.microsoft.com/t5/microsoft-viva-blog/microsoft-viva-pulse-available-for-public-preview/ba-p/3838338 Viva amplify gets a release date (sorta) Viva amplify is one of the viva tools that I’m looking forward to from a content creation perspective - it’s a tool to let content managers create campaigns and send them to employees via other tools like Engage (Yammer), Teams, SharePoint, Outlook etc, and then view metrics on how their campaigns are doing. The roadmap has finally been updated to say that it’s coming in September, so I’m looking forward to that! Viva Connections notifications This one is a must beware for IT-admins, a change is coming that’ll make viva connections send notifications, such as @mentions and info about news posts in SharePoint - while I like the idea, this worries me since Viva connections is a part of teams, I fear that users will find these notifications annoying and turn them off, and then miss out on important notifications from teams. Dev Fluent 2 If you’re a dev in the M365 space, you’re no doubt familiar with Fluent UI (formerly Office UI fabric) which is a design toolkit/framework from Microsoft for building apps for M365 that look like they belong! - Fluent 2 is the next iteration of this, and it’s now in publicly available! The main focus is on harmonizing the design language across all Microsoft products, from SharePoint to teams. https://fluent2.microsoft.design/ Graph Toolkit v. 3.0 If you haven’t played with Graph Toolkit now is the time to do so! - It’s a super cool collection of web components that let’s you get started, and build apps with the Microsoft Graph in no time! New in this release is better picker components, dark/light mode switcher, improved SPFx support, and the big one for me, account switching! https://devblogs.microsoft.com/microsoft365dev/microsoft-graph-toolkit-v3-0-is-now-generally-available/ OneDrive and SharePoint Favorite documents in SharePoint Another small feature that deserves a small shoutout is the ability to favorite documents in SharePoint, and then find them in your OneDrive later, sorta like the add shortcut to OneDrive, but for a single document. A great way to aggregate the files you often work with in a single place. PDF Annotation and Fillable Form Fields FINALLY - We can now edit PDFs in OneDrive right in the browser, while the feature is very lightweight, it’s a great start, we can highlight, make some quick doodles, or fill out forms, but we can’t add text, images, or sign documents, but it’s a start! I’m really hoping we’ll see this coming to SharePoint some time in the not too distant future, but I’m not holding my breath since this might be competing with certain syntex features. OneDrive simplified sharing Permissions in SharePoint and OneDrive have always been tough, Microsoft has taken another stab at improving the experience, they seem to do this every few years, I really like where it’s headed this time around, a few less options for the user to care about, more focus on the end result, and a better experience for the end user. (if the admin has configured the default settings correctly) TL;DR Microsoft certainly didn’t slow down in June, maybe July will be the month where we get a small break, isn’t it awesome being on a platform that’s constantly evolving? - I think so! --- ### Why you should join a hackathon Forfatter: Dan Toft Dato: 2023-06-02 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-06-join-hackathon/ Right now (June 1-15 2023) - Microsoft is hosting a hackathon, but what is a hackathon? - and should you join it? What is a hackathon? A hackathon is essentially a nerd race! - often limited by a set timeframe (14 days in the case of the current Teams HackTogether), usually there’s a set of boundary you have to stay within - this can be anything from “solve our business problem”, to as in this case build something that runs in teams! I’ve attended hackathons hosted by smaller organizations while I was still studying, these where used as a way for the organization to find interns, but disguised as a fun event, with free pizza and beer, and some nerdy prices! Microsofts hackathons are slightly different, first off, they’re usually longer then the 1-2 days that a classic hackathon takes, and they’re not used to find employees, but rather to get more developers to see the awesomeness of developing for Microsofts platforms. Why should you join a hackathon? So joining a Microsoft hackathon won’t get you a new job, at least not right away, but your open-source contributions are great to use in a portfolio, so not only do you get to learn something new, but you also get to show it off! Secondly at least with Microsofts hackathons, you get to talk directly to industry experts, so if you have any issues, you can get help from the people who actually built the platform you’re building upon! They’re also not just a good way to learn a new thing, and show it off, it’s also a great way into the communities surrounding whatever technology the hackathon you’re joining is focused on. Lastly, it’s just a great way to have fun, and meet new people, and who knows, maybe you’ll even win a prize! or at least get an awesome badge! What are the downsides? Downsides you say? - well other then learning new stuff, and having fun, there’s not really any downsides, so unless you hate those two things, there’s no reason not to join one! But I’m not good enough! Nonsense! - A hackathon is not about being the best, it’s about having fun, and learning something new, so even if you don’t win, you might have learned a new skill that you can use later in life, which is way more powerful then any xbox you could’ve won! Remember, every contribution matters! So what are you waiting for? join now! aka.ms/hack-together-teams TL;DR Hackathons are a great way to let your inner nerd out, and learn something new, while having fun, and competing for a chance to win cool prizes! --- ### May in M365 Forfatter: Dan Toft Dato: 2023-05-25 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-05-may-in-m365/ May started out with the super awesome M365 conference, and ended with build! - while I didn’t get to go to either one that doesn’t mean that I won’t be covering the updates from there, many of which unfortunately won’t be out before later this year, so let’s start by taking a look at some of the stuff that launched this month! Outlook on the web work hours and location Outlook online (and the new Outlook) got a nifty new feature this month, “Work Hours and Location” a nifty way to setup and share your work schedule, post pandemic we’ve seen a change in how people work, seeing Microsoft staying on top of this and allowing users to set a custom schedule is awesome! The work week planner Now we’ve had working hours for a very long time, what’s new here is the option to set a location, this is great for those times where you want to schedule a meeting, being able to get an overview of where your colleagues are so you know if you’re doing an in-office or a teams meeting. We’re now also able to setup “split working days”, so you can work remote from 08:00 to 11:30 and then maybe you have to pickup your kids you’ll be working again from 18:30 till 22:00 you can now do that without filling your calender with random “blocks”. Now not only can you do this on a schedule, you can also change this on a day by day basis, if for instance you’re having a plumber come by, you can just mark tomorrow as a working form home day, unfortunately I haven’t found a way to set several locations on a single day without using the “bigger” schedule tool. Edit forms responses This feature is pretty neat, it does require admins to enable it in the admin center, but once it’s been turned on, users creating a form will have the option to allow respondents to edit their answers, this is a great feature if you’re using forms for things like “registrations for the christmas party” or other things in that ball park. Modern document sets Another really neat very minor thing that came up this month was actually a bit of a accident, I discovered that flipping a small switch on document sets causes them to modernize, apparently no one else had found this either. Daniel Anderson made a great video about this here. New look for Logic apps Something I’ve really not seen addressed anywhere but that I find pretty neat is that a new look and feel for logic apps has started to roll out, now pure speculation on my side says we’ll see this hitting Power Automate within too long, and there’ll be more of a focus on it then. But it looks more “grown up” and less “lego’y” then the old UI huge thumbs up from me! M365 Conference Now for the big one this month! Microsoft 365 conference happened in the beginning of the month, and probably deserves a post of it’s own, but I’ll try to summarize the what, when and whys that matter to you as a M365 user! Most of those features unfortunately won’t be available till later this year, or maybe even early next year! Here is a roadmap with some of the features that’re coming! New UI for SharePoint The first major thing that’ll truly affect the end users is what I would call modern UI 2.0, we don’t have a lot of details yet, but we’ll be able to customize SharePoint pages waaaaay more then what we’re currently limited to! - we’ll also be able to co-author SharePoint pages, two users can’t edit the same web part at the same time, but the same page without a problem. The following gif is a snippet from the official Microsoft Slides. Brand center & BYOF Along with the above awesome change we’ll also be getting more tenant customizations, what exactly those will be we don’t yet have a full picture of, but we did get one major thing! BYOF (Bring your own font) - soon we’ll be able to bring our own font to SharePoint, I’m looking forward to seeing intranets using wingdings! - this for me is one of those things we’ve wanted since the start of day, I’m really looking forward to seeing how Microsoft will implement this as to not break when people bring crazy fonts! Copilot is everywhere Copilot is Microsofts big promise with AI, I’ll have a full blog post coming on that when I get access and have actual experience to share, as for now it’s looking like it’ll be ChatGPT hooked into your M365 environment! The main takeaway from M365 conference regarding Copilot for me was the fact that Copilot truly is coming to everything, from loop to Outlook to SharePoint pages, really everywhere! This is also a great reminder that we’re nearing the end of hiding content behind obscurity, Copilot will find the content if the user has access to it! Lists Another area where we’re seeing loads of improvements is lists! - a product we’ve known and loved for years but in recent years it has gotten a lot of love is now getting even more love! Lot’s of this stuff is already making it’s way to the MSA (Microsoft accounts) lists version, and we’ll see them making the way to our organization accounts soon enough. Performance Microsoft has really put a lot of effort into improving performance, allegedly we’re going to see an performance increase that’s close to 50%, loads of this coming from a behind the scenes switch away from IndexDB in the browser. And loads of UX improvements to make it more intuitive, and faster to use! Forms Something really cool that I’m personally looking forward to is the ability to not only customize the new and edit forms more then we already can! - but also have several different forms on the same list, and share those forms, not just internally, but also externally (potentially even anonymously - still waiting for full clarification on that one) Co-authoring Another thing that for me is a genuine gamechanger, co-authoring in lists, soon several people will be able to edit the same list at the same time, like we’ve been able to in excel for a while, this is something I hadn’t considered the need for, but now I don’t know how we’ve lived without! For a lot of collaboration tasks this is pretty cool, imagine tracking tasks during a meeting but now you’re no longer limited to just a single note taker (you could also use loop for this, but that’s still a bit out for full public release) Send a page as email Something I’ve often had requests for that we’re now finally seeing is the ability to send a SharePoint news post as an email! - we’ve been able to share them as a link for a while, but being able to share the entire post as an email is super awesome! Open all files in client This was just a side note (at least on twitter where I saw it), but I cannot stress just how cool this is, the number one request I’ve heard over the years is “can I open PDFs in the client like I can with office documents”, FINALLY Microsoft is making some promises to resolve this long standing issue, leveraging the OneDrive app you’ll soon be able to open any file stored in OneDrive or SharePoint in it’s native client! New OneDrive homepage Another really awesome one for me is a new look and feel for OneDrive (and who knows, maybe in the future SharePoint as well) - this new look is more glassy, a bit like we’re seeing in Windows 11, it’s nice to see Microsoft slowly aligning their design language cross product! A tiny really nifty feature that I think will have a bigger impact then you might first imagine, not just for accessability, but also for the end users to feel like the product is theirs, colored folders, it’s a small thing, but it adds a lot of personality to the experience of using the app. Tenant to Tenant content migrations FINALLY! - Microsoft announced something I know hundreds of organizations have wanted throughout the years is finally coming! - built in content migration from tenant to tenant, this seems like we’ll see a very limited, but it’s something that many orgs spend lots of money on each year, Microsoft seems to have aimed for the lowest possible barrier here, so we’ll only be able to migrate content, whatever that means, I’m imagining that’s things like: Files, SitePages and Lists with items, but not any custom solutions, so this isn’t a end all be all tool, but it’s a free option that’ll do for maybe 80% of the mergers that would today need a third party tool like like ShareGate. Microsoft Build Another conference that took place this month was Microsoft Build, Microsofts big developer conference, unfortunately at the same time as European Collaboration Summit where I was attending, but that didn’t stop me from reading the book of news, and picking out a few things that really stood out to me! Copilot extensibility We heard news that Copilot will be extensible, with support for the Power Platforms Custom connectors, I’ve also heard we’ll get support for teams message extensions, but I’m unsure if that’s been officially confirmed anywhere yet. Teams toolkit will also get a template to build these pretty soon, so you wont even need to change your existing tech-stack to get started! Azure OpenAI Service Azure OpenAI Service’s Azure AI Studio is going into preview now. Ai Studio is a tool that’ll allow orgs to mix in their own data with the LLMs. Microsoft also but on a big talk about how they’re making sure the AI “behaves well”, making sure it’s ethics still apply, even if you feed it unbalanced data, there should be no way to train an evil model. Dev drives (is WSL still relevant) This will be a new storage volume type in windows, optimized for development, I’m really hoping this will be a performant drive type that can help with things like escaping anti virus scans, and since it’s a new volume type, maybe we’ll be able to avoid having to use WSL for performant NVM installs, since they might redo the way they manage the files! We will se Copilot for the following products Dynamics PowerBi Fabric Windows Edge Syntex Power Automate Of course this is super cool, but I’ll try to steer clear of any promises till I have it in my hands A new Edge for business Bringing a bigger focus on security, and hopefully the customizations organizations often demand, to prevent scenarios like recently where Microsoft added the Bing-GPT button in edge and many larger organizations didn’t appreciate their users using it, or seeing the button there! Microsoft Cost Management This one for me is really cool, a tool that backed by AI will help you better understand your cloud bill, ask it questions using natural language, and get useful responses, if it’ll be able to check for things like unused licenses is not clear to me at this time, but just on the azure side, being able to ask “what’s costing me soo much money” is awesome, and will help any IT admin around the world! - discovering that you’re spending 75% of your budget on a SQL server that you’re not actually using is never fun. Microsoft Fabric A new tool for analytical workloads, integrating AI, Power Bi, Data Factory and Azure Synapse in order to improve your reporting experiences by bringing all the tools you’re already using together with AI. GitHub advanced Security for DevOps goes into preview GitHub advanced Security for DevOps is something that those of us who’re stuck using Azure DevOps have been looking longingly for, features like Code scan, Secrets scanning, and dependency scanning - you know that annoying dependabot email you get once a week? - yeah, that’s coming to your actual production environment, not just your hobby projects! - I find the branding part to be the most interesting of this, keeping the github name to me looks like we might be looking into to a future where DevOps and GitHub are moving closer and closer together. Loads of “Confidential” azure stuff We’re seeing loads of confidential things, things like kubernetes, hardware level trusted VMs, it looks like Microsoft is pushing hard to make Azure an actual option for orgs where security/governance is of high importance, this isn’t a field where I do a lot of work, so I don’t actually know a lot of what options we have today, but seeing even more security options is always awesome! Containers We’re also seeing a big push towards containers in development, an awesome concept that makes development more streamlined, things like deploying containerized Azure Functions in an Azure Container Apps. Azure API Center A new service in Azure that’s a feature of Azure API Management platform that enables tracking APIs in a centralized location for discovery, reuse, and governance, seemingly without the “protection”, and therefor costs normally associated with APIM, I’m really looking forward to playing with it. Microsoft Syntex Repository Services Here we’re getting what Microsoft is calling Syntex repository services, from the looks of it, we’re getting a headless SharePoint site, meaning there’s no UI out of the box, but you have full access to all the APIs, this means you have all the benefits of being inside your M365 environment, like auth, governance and compliance, permission management, collaboration(things like @mentions), and a clean slate for you to build apps on top off! I don’t see any word on pricing yet, but I suspect we’ll see this as more of a pay-as-you-go sorta thing then something that’s consuming your SharePoint storage - this is already what’s powering Loop and designer behind the scenes! Smaller stuff from Build Mesh Avatars are going GA There will be an option to build our own mesh rooms Document translation in language Studio goes GA More ARM support on windows This focus makes a ton of sense with the ongoing rumor that Microsoft is currently working on their own SOC Github Copilot X in windows Terminal Work is being done on Graph APIs for Audit scenarios Power Platform excel to app feature (I want to see that before I believe it) TL;DR What a month may was, loads and loads of announcements, less in terms of actual releases, but we got a great insight into the direction Microsoft is headed with their M365 suite! - and as always you should be excited to be along for the ride! 🏎️ --- ### What are document sets, and are they relevant in 2023? Forfatter: Dan Toft Dato: 2023-05-08 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-05-document-sets-relevant-2023/ Document sets Depending on how long you’ve been working with SharePoint you probably either just had a flashback to good old classic document libraries, or you’re sitting here staring into the void thinking document whats? What are Document sets Document sets is a fairly old feature that sorta got lost in the process that is the SharePoint classic to Modern UI transformation, and while they technically made a return to modern all the way back in 2019, they still haven’t gotten all the way back to their former glory, often referred to as “the blue folders”, they’re a special folder type. Of course you’re able to place files inside them as with any other folder, but the first an most obvious difference is the option to tag them with metadata, but with a bit of trickery, you could also do that on a “yellow folder” using quick edit, so why are they so special? Shared Columns Now why would I want metadata on a folder, well for one it improves searchability, and this is also where document sets have their first really big advantage, “Shared Columns”, with a document set you have the option to “share” specific columns to EVERY file that’s inside the document library, no matter if the content type of the document has that column or not the value will be set on the files, this is really useful for search. A classic use-case that you’ll see is a CRM integration, where every opportunity is created as a document set, with a customer column auto populated, this way you can setup a search portal where I can now filter for documents by the customer - making it easy to find what you’re looking for, even across projects! Default content With a document set you have the option to set default content, this means you can automatically create a set of files and/or folders every time a new document set is created, you can even add the name of the document set to those files - that means you can configure it so when you create an opportunity there’ll already be a ‘data processing agreement ready’ for the client to sign, making sure there’s no excuse not to get it signed. Limit content types Another really neat feature, is the ability to limit content types within a document set, so you could theoretically create a setup where you cannot add a data processing agreement to a “self-hosted client” - this is a feature that takes a bit more setup to get right, but when you get it right - it’s a gamechanger! Issues Classic layout Now it’s not all fun and games, unfortunately whiles’t document sets works in modern UI, Microsoft for some reason still here 3 years after enabling it still haven’t modernized the UI to create them, so users will be redirected to a classic page that looks like … well Classic SharePoint Now recently Microsoft created the option for us to control the new form with SPFx by creating form customizers - but I’ve found some odd behavior whereby when you NULL the new form id for it to use, you’ll actually get a modern experience, why this works, or if it breaks anything else I’ve yet to figure out, so do this at your own risk. I’ve created a script to do it here Welcome pages A feature that never made it to modern UI from classic for some odd reason is the Welcome Pages, they where a way to visually display certain columns, like the description of the document set at the top of the screen when you opened the document set, this meant you could have things like a “project brief” right there, front and center. Instead it seems Microsoft has opted to force the information pane on the right to always open whenever a user enters the document set, no matter if it was open or closed prior to them clicking it. This provides some of the same aspects, but in a much less “visual” way then before. Getting started - disabled site feature Document sets are a Site Collection feature that you must first enable, and then you can create content types that inherits from them, with the columns that you need, this is a minor thing, but puts the feature out of scope for most newer users, unfortunately. It would be nice to see Microsoft do more to raise awareness of this awesome feature, but I guess it’s a chicken and the egg kinda situation. Programmatically Working with document sets from code is actually pretty straight forward, once you’ve done it a few times, the important part is that it’s a two step process to create them. First you add a new folder, and then you’ll need to change the Content Type. Another thing to be aware of is that you need to set two values in order to rename a document set, both the FileLeafRef and Title. { "Title": "New name", "FileLeafRef": "New name", "ContentTypeId":"0x0120....." } If you’re using things like PnP Framework there are built in extension methods to handle this sort of thing right out of the box, so you should be golden there. TL;DR Document sets are a great way to introduce more searchability into your documents, and streamline processes of document creation, and metadata maintenance. --- ### April 2023 in M365 Forfatter: Dan Toft Dato: 2023-04-29 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-04-april-2023-m365/ Here we are, April is coming to an end, and oh boy was April another crazy month in M365! - Time flies, and in just a few days M365 conference will take off, and we’ll see even more crazy stuff coming up! But before we get there, let’s take a look at what has happened in the past month! Teams teams and more teams! The new Teams Last month was crazy, but low and behold, within hours of publishing that previous post Microsoft announced the public preview of the new Teams windows app, this is a preview of what’s to come. Right now it’s very much a “lightweight” client, missing certain features that would be nice to see, such as 3. Party apps, search, teams creation, and some other things, but Microsoft are working very hard to bring this new client back up to speed with the “old” client. Snapshot of the current feature list The main focus appears to have been performance, some of the stats I’ve seen online are twice as fast, 50% smaller install size, waaay less memory usage. It’s really good to see Microsoft taking the approach of “let’s try this with a clean slate”, I’ve seen a lot of criticism online of the approach, but I think it’s important to realize that Microsoft is building a product, not running a project with an end goal, so this was probably the right thing to do! Snapchat filters Another feature that Microsoft implemented is Snapchat filters in Teams … I’m really not sure how to feel about this one, on the one hand I really like it, it’s great fun for informal internal meetings, but the very first time someone called me after I had played with it, I picked up pressing the camera icon in the notification, and now I’m sitting here in front of my client with a chicken on my head, not sure what to think, but it’s a thing, do with it what you want, or maybe turn it off. What even is the point of this? Teams avatars Mesh avatars also went into public preview this month, avatars are a great way to handle camera burn-out, often times you might be eating, working from home, or something else where you don’t want to be on camera (or just not feeling it), now you have the option to create and use an avatar to represent you on screen instead, it’s actually a pretty genius way to relax a bit more, and definitely something I see myself using in the future for those meetings where you’re just not wanting to be on camera! Avatars in a meeting Spatial audio in Teams meetings One last thing that I want to call out on the teams side of things, that really flew under the radar in my book is spatial audio it is a new feature that’ll allow you to hear people in more of a 3D spatial way, this means that if someone is on the left of your screen in that camera grid we all know and love, the’ll sound like they’re to the left of you! Really looking forward to see this one come more to life later, hoping for things like together mode support, or maybe even meeting room support! SharePoint Better Page Authoring Experience Microsoft is making some changes to the page authoring experience, such as being able to switch layout types outside the property pane on web parts, being able to customize text on images, and making the add section feature easier to find, these are all just small tweaks that add together to make the whole product more coherent, and intuitive for new users! SPFx 1.17 This one is more for the devs - SharePoint Framework version 1.17 released, the main thing for “clients” is that the “top actions” we saw in 1.16 now went GA, (also makes sense since we’re seeing it in MS web parts now) Other then that we saw a ton of dev awesomeness, from ACEs getting Adaptive cards schema v1.5 to improvements to authentication. The option to have a additional properties right in the authoring experience Filters in Microsoft lists Another change that’s coming is a smaller detail when using Microsoft/SharePoint lists, you’ll be able to see which filters you’ve got applied currently, this is an awesome idea, it’s something that can be seen in the URL already, but brining it front and center for the users is awesome! This feature is set to release April 2023 on the roadmap, but I have yet to see it on any of my dev tenants. Power Platform Modern controls in canvas apps Something that I’m more excited about then what I probably should be is that “modern controls” for Power apps went into public preview, this means that now you can take your awesome Power App, and make it look modern and nice without being a CSS magician! Definitely looking forward to playing more with this one! The ‘classic’ layout of Power Apps The modern controls Power Platform 2023 release wave 1 I won’t cover this one too much (there’s a 190 page PDF explaining it if you’re curious) but the 2023 release wave 1 dropped for power platform. One of the key things I really liked is the natural language generation of Power Automate flows, I had a ton of fun playing with it, and even got to demo it for a class of computer science students who’s minds where blown, but also some of the layout changes in CRM were pretty neat! Viva Glint Microsoft announced a great partnership/integration with Glint, a market leader in employee feedback meets Microsofts AI, this looks really promising, having an option to in natural language ask about your employees well being, this is really an awesome addition to the Viva suite, which already has a ton of data on your employees work habbits, adding glint to that is something I’m really looking forward to see create a ton of value in loads of organizations! Microsoft 365 Unified domain Cloud.Microsoft Microsoft has decided to unify all of their many cloud solutions into a single domain, this is done to streamline the user experience, and hopefully help to improve security as Microsoft owns all rights to the .Microsoft top-level domain, meaning no one can make a “fake” page that has a URL like team5.cloud.microsoft, ideally no user action should be needed for this change, BUT you should really warn your users once this starts rolling out. TL;DR You’re on a constantly evolving platform, and teams has really been getting overhauled this month, not only with a new version, but also loads of new features! In the coming month we’ll see the M365 conference in vegas which promises to bring what sounds like “the next SharePoint”, I don’t know about you, but I’m SUPER stoked! Sources The new Microsoft Teams (Preview) Snapchat filters Avatars for Microsoft Teams in Public Preview Spatial audio SharePoint: Improving the Page Authoring Experience SPFx 1.17 Applied filter Modern controls in canvas apps Microsoft Power Platform: 2023 release wave 1 plan Microsoft Viva and Glint Introducing cloud.microsoft: a unified domain for Microsoft 365 apps and services --- ### Uncovering the mysteries of the missing user profile images Forfatter: Dan Toft Dato: 2023-04-14 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-04-uncovering-mysteries-missing-user-profile-images/ What is user profiles? Users profiles is a mysterious place, it’s a database of users from your AD, but mirrored to SharePoint, it’s also the foundation of Delve, it also provides additional fields that aren’t in the AD, for people to populate themselves, such as skills, education and projects you’ve worked on. It does however seem like Microsoft is moving this stuff to other platforms, and slowly killing off delve, such as the pronouns feature ending up in the Azure AD, cover photos in Viva engage, and the whole myaccount page which as been getting overhauled. - *this is all my best guess, I have seen no official statements from Microsoft to back this up! - so take it with a grain of salt * The profile photo issues A common issue for quite some years now has been IT updating the profile photo in the Active Directory and it for some reason not syncing to SharePoint, this would give a super detached experience where you could see a user without an image, hover your mouse over them, have the profile card pop up only for them to have a picture in the card. Now Microsoft is slowly moving their official web parts to use the Graph API to pull photos, so the out of the box experience is surely getting better, but many old custom web parts will still have issues (there are 90 references to userphoto.aspx in the PnP SPFx samples) and it’s still not possible to use the graph API in column formatters, so it’s hard to do a low-code solution that has access to the right photo. So we really do need that photo to be in sync! - but unfortunately it seems to get out of sync, or just not sync more often then what’s reasonable, and for a myriad of reasons that aren’t clear to neither users or IT, heck I still haven’t found a “one size fits all” explanation, but I’ve found a few things that I would like to address. Prerequisites for images to sync It is a requirement that the user has a Exchange Online Mailbox, just having the license isn’t enough, so if you’re running an hybrid environment with exchange on prem, this is because there actually isn’t any OOTB sync from Azure Active Directory to SharePoint Online, it’s actually a Azure Active Directory to Exchange to SharePoint, and only upon the user requesting a photo of themselves (visiting a SharePoint page) this is also why it can sometimes take days for the full sync to complete. Diagram showing the complexity of user profile photo sync Photo credit What can be done It’s more then possible to manually sync the photos around the sync job, but before we go down that path, Microsoft somewhat recently released a pretty neat tool that’ll help you diagnose why a single user isn’t getting their photo updated, it’s in the admin center, give it a try if you’re still having issues, syncing manually is definitely an option. Manually syncing The photos are stored in the my-sites site collection: https://{Tenant}-my.SharePoint.com in a document library named ‘Profile Picture’ (or whatever that translates to in the tenants original language.. yes they localized that location) In here there will be three photos for each user, named based on the users guid from the AAD: Name Dimensions _SThumb.jpg 48x48 _MThumb.jpg 72x72 _LThumb.jpg 300x300 After this you can set the PictureURL property on their user profile and it’ll update all around SharePoint almost instantaneously. Unfortunately this cannot be done with application permissions, you need a legacy ACS authentication with Full control on both social and content at tenant level, which is frustrating. FULL DISCLOSURE! - I have no idea if this breaks the sync from exchange, or if exchange just overwrites after a few days, do it at your own risk. TL;DR Microsoft doesn’t seem to care about the photos not syncing as they might slowly be killing off user profiles. If your sync is not running check the following: User has photo in Azure Active Directory And is not a guest User has an exchange online mailbox User has signed into SharePoint Online a few days after photo has been added to AAD Try Microsofts own diagnostics tool If for some reason it’s not possible to fix one of the prerequisites, it’s possible to manually sync the photos around the built in sync job. Resources The official documentation Official help guide for photos not syncing --- ### Dealing with the 5k item limit in SharePoint APIs Forfatter: Dan Toft Dato: 2023-04-11 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-04-dealing-5k-item-limit-sharepoint-apis/ Before we even get started talking about the list view threshold, let’s make one thing VERY clear! Microsoft lists is not a relational database! Now that we’ve got that out of the way this post will summarize my experiences working with large lists, some with upwards of 500k elements, without folders, it’s more than possible - is it a good idea? - let’s have a look. What is the list view threshold, and why it’s there If you’ve worked with SharePoint as a dev for some time, you’ve eventually run into the issue of “The number of items in this list exceeds the list view threshold”, Microsoft has a few tips on it, the main one being “use modern” - well that doesn’t help you when you’re using the APIs, not even via Graph API, so what do you do? Firstly, I want to address why it’s there, I’ve seen a lot of people saying Microsoft should just remove the limit, you could do that on-premise, but I genuinely disagree with this stance, it’s there for performance reasons, but also as a dev you should really stop and think “am I doing something wrong here” anytime you’re fetching 5000 rows at once from a SharePoint list. So the limit is there to save the system from performance degradation, but also limit just how slow a query you’re doing can get, not just to prevent timeouts, but also to prevent a bad user experience. Now If you’re getting the message there are a few things you can do to get around the issue, let’s have a look at those: Filtering Firstly, the most simple thing to do, and something you should always be doing, is to make sure that you’re filtering the dataset to limit how much data you’re fetching, using the REST APIs you can do this with OData filters, or CAML, both have the same limits in terms of the 5000 items, so really just go for whichever one you’re most comfortable with. The filtering order matters! Now something that took me longer then I would like to admit to wrap my head around is that the order in which you write your filter actually matters, coming from an SQL background, I’m sure the same applies there, but you don’t notice it in the same way since there aren’t the same limits. Now due to the nature of how this limit is applied, no specific part of your query can result in a result set of 5.000 items, this means choosing the order of your filters can have a huge impact. Looking at the contacts list at the bottom of this page, let’s say I want to receive a list of all consultants at ‘Company A’, I could filter in two ways: Job title == 'Consultant' and Company == 'Company A' or Company == 'Company A' and Job title == 'Consultant' Both will leave me with the same result, but depending on how the rest of my data looks, one might work and the other might fail. They’re evaluated left to right, so the two statements executing will look something like this: Job title == 'Consultant' and Company == 'Company A' - Give me everyone with the job title ‘Consultant’ - 2 results - of those, give the ones who work at ‘Company A’ - 1 result Company == 'Company A' and Job title == 'Consultant' - Give the the ones who work at ‘Company A’ - 1 result - of those, give me the ones with the job title ‘Consultant’ - 1 result Now this is a fairly simple example, but if our list had more then 5001 people with the job title consultant, the first statement would actually fail, same goes the other way around. So keep the order in mind when you’re designing your query, always query by the most distinct property first, and then narrow down from there. Indexing Now we’ve talked a lot about filters in the above sections, once your list grows beyond 5.000 items you’ll need to index columns in order to filter using them, think of indexing as marking a column for query - behind the scenes a lot more is going on that we’ll not worry about today. Most of the time if your list just grows slowly, SharePoint (Online) will actually automatically setup indexes for you, but in some cases I’ve found that to not work, so just set them up manually if you know you’ll be using them. This is done by going to list settings under the columns press the Indexed columns link, Create a new index and you’re off to the races, you’ll be given the option to add a secondary column, here in theory this is supposed to improve performance when those two columns are queried together, but it does not change any of the limits, and from my twitter feed it seems to be mostly theoretical. Dynamic data Now common for the two things above is that they hardly require any changes to your codebase, maybe a rewrite of your filter, but that should really be about it. If the above two do not solve your problems, maybe we’re attacking the issue in the wrong way, if your app shows a long list of data you really should consider loading in more data as needed, for instance when I scroll to the bottom of the list, this can be done using paging, or better yet the RenderListDataAsStream API, that’s how the modern list views manage to show large lists (and also why you sometimes see the load more button on the page). This will not only allow you to show data from a very large list, but also really improve the user experience of your app, as you’re no longer fetching all the data before showing it, but rather you’re fetching it as you need it, this does of course mean that you have to do a bit more of code rewrite, so do be sure to test that the API does what you need it to before going down this route. Archive data Another option is to archive data - and here I’m not talking about having a inactive flag on your items, although that could actually work, I’ve seen it done as a “temporary” fix - a flow that sets a boolean on every list item at the start of every week, and every query to the list just has Archived eq 0 but at this point you’re already peeing your pants to stay warm, please don’t actually do this unless it’s very temporary. The right solution is to move your data off to somewhere else, this could be an SQL instance, Dataverse, or even just a separate SharePoint list, it’s important to realize that data has a lifetime, and while you might not want to delete the data, you should still archive it once it’s no longer actively being worked on! Other data sources SQL Something I realized at one point while helping out an individual who was stuck with a list view threshold issue was that the person knew that SharePoint lists wasn’t the correct approach, but really liked the provided APIs from SharePoint, and I mean, who can disagree, OData is pretty awesome, but something that not everyone knows, is that OData is a library that you can easily implement in something like Azure functions, checkout this sample Azure Functions with OData, now combine that with Entity Framework, and you’re off to the races. This way you won’t have any problems dealing with many thousands of items at a time. Dataverse Another option is Dataverse, Dataverse is the database behind the Power Platform, it’s essentially an end user database that’s easily setup and managed by a power user, and easily connects with the Power Platform. While Dataverse does come with a slight license cost, so does running an SQL server in Azure, here you’re given everything from security to APIs right out of the box. TL;DR The threshold is there to ensure the performance of your app, make sure you index your columns, filter in a smart and efficient way, and consider using modern APIs like RenderListDataAsStream if you’re building a responsive app. And remember, data has a lifecycle, archive it! Resources Sample Data - Contacts list First Name Last Name Company Job title Dan Toft Company A Consultant Casey Smith Microsoft Manager Jim Smith OpenAI Manager Brian Jacobsen Contoso Consultant John Doe Contoso CEO … Read more Community docs - Living Large with Large Lists and Large Libraries - great tips if you do deiced to stay in a SharePoint list, or you’re on-prem. --- ### March 2023 in M365 Forfatter: Dan Toft Dato: 2023-03-25 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-03-march-2023-m365/ The month of march has been super packed with awesome announcements, product launches and previews! - and while it’s not over yet, let’s take a quick look at some of them. The minor stuff Teams files I don’t know if you know this, but teams has a files “app”, now I never really use it, or introduce clients to it, as it’s pretty damn useless, for me it usually just shows a bunch of random files that I’m not sure what to do with, and don’t know when I worked on - I always wanted it to be more smart, and find the files I might actually need. Well it’s getting an overhaul that looks really promising, pulling content from SharePoint, OneDrive and everywhere else where you interact with files on M365. This is a minor change, but a very welcome one, this means using the files tab in Teams should soon become a lot more like the OneDrive homepage, with some more “easy to understand” filters for filetypes, hopefully making it quick and easy to find what you’re looking for. Microsoft Drop Drop was one of those product announcements that really took me by surprise, it’s a simple product idea, but it solves a years long problem, at least for us Windows/Android users - years ago Apple announced Airdrop, a quick way to share files between your Mac and iOS device. I’ve wanted a native feature for this for years, now we’re finally starting to see something in that regards on the windows/android ecosystem - Microsoft announced drop - an edge feature that let’s you drag n’ drop files from your PC to your phone with the only requirement being having edge on both devices … oh by the way, it also works on mac and iOS. now what’s really happening here is that the files are being uploaded to your OneDrive in a more convenient way. Also it does not enable the “nearby share” that AirDrop has (yet) - but being able to share files and text from right where you already are is awesome, and honestly as a long time chrome user this might be enough to bring me over to the edge train. New “my groups” experience Honestly, when I heard of this announcement on the 365 Message Center Show I was about as blown away as the hosts that this “product” even existed, apparently there is a mygroups.microsoft.com, that will very conveniently list all the groups that you’re a member of, and also a convenient list of the groups you’re an owner off - man the number of times where this would’ve been nice to know to quickly help diagnose an issue. Well it’s getting a makeover, and a new URL that the existing one will redirect to (myaccount.microsoft.com/groups)[https://myaccount.microsoft.com/groups/] - to me it makes a lot of sense to move it under the myaccount resource. It’s also getting a more modern look and feel, which is a great addition because the existing one, well looks a bit dated, using a more “classic” square look and feel, where as the new one uses the more soft corners, and colors of modern M365. Planner templates A very small feature that I haven’t seen enough people talking about, perhaps because it’s still in the very early roll-out stages are templates for Planner, this one is a major feature! - currently only a few limited default templates are supported, but with Microsofts focus on templates across the products my gut feeling tells me that we’ll see options to build our own in the coming months! This is a huge benefit as this is something I often meet in the field as a request, and while we could build this ourselves with the Application permissions that launched back in january there’s also a cost associated with that, so getting this out of the box will be a great benefit to many - It’s also something that Trello has had for years, and that makes it hard to sell Planner as a real replacement. So as excited as I am for this one, I’m more excited for what’s to come! - but this is a great start to “show the art of possible” The two big once Microsoft Loop For a what feels like close to a year now we’ve had loop components in Teams chats, and it’s a pretty nifty feature, I’ve found myself using it more and more over the recent months, in the beginning I had lots of issues with users getting access denied when I had just created a component in our chat which never really made sense to me - but it like so many other first generation products have gotten a ton better, and the components are great for things like working on a table of pros or cons, an simple agenda for a upcoming meeting, the collaboration options are really nice - and with the option to embed them in more places from Outlook web to Word online there’s definitely huge potentials for loop as a whole. But this month we got the biggest “update” yet, loop launched as a product by itself, currently you need an admin to enable it on your tenant, due to it not yet supporting the compliance features that are required by most organizations, so I had to use my dev tenant to play with it, and didn’t get to really enjoy the full collaboration experience just yet. But from the little bit of playing i did with it by myself what I’ll say is this is OneNote 2035, it’s fast, it’s fluent, you get this super nice editor that just makes it super easy to generate content that looks sleek - It’s almost like Markdown - but less complicated, and faster to use because there are shortcuts to generate things like a table, honestly, if the integration between a Loop workspace, and a Teams team where more seamless, I would be replacing all my OneNote usage with loop - for now it’s definitely my new personal notes app … or will be once they release the Android app, there’s an open preview for the iOS app in case you’re interested. Copilot It doesn’t feel like it’s all that long ago we had the pleasure of getting Github Copilot, back then my brain exploded, and then came Chat-GPT, rinse and repeat - honestly I don’t need to cover that, it’s been covered a billion times Chat-GPT is freaking awesome! Now Microsoft announced Microsoft 365 Copilot, no release date or anything yet, it’s only currently being tested at 20 companies world wide, but from what we’re seeing and hearing this really looks like it could very well change the way information workers do their job, and not just a little bit we’re talking massively, like to the level of everyone having their own PA. Essentially it’s bringing Chat-GPT to your tenant and into your apps, writing an email and realize you’re a bit frustrated in your tone? - simple prompt Please rewrite this email bot more formal - there you go, ask it to draft you an email to the client telling them that you’ve gone over budget due to them changing the specs, Draft me a 2 paragraph email going over why we've gone over budget due to changes in the scope. - ‘Well Chat-GPT can do those as well’ - yeah, but this is where Copilot takes it to the next level! - need to find out which month you did the most revenue pr. working hour - simple open your revenue excel sheet and ask Copilot, right there within your spreadsheet! Now all of those are very “isolated” tasks - just like helping with grammar in word, which it’s also supposed to do, but what I’m really excited about is the broader aspect, prompts like “I just got back from vacation, summarize all my emails from the last week, and let me know which once I should act on immediately - and which once I can just delete. Need to know how in your org might know something about wind turbines, just ask it, “Who has worked on clients that deal with wind turbines?” and it should know. What is really awesome is that it gathers all of it’s knowledge from within the walls of your tenant, this means you shouldn’t find it returning answers that don’t apply to your org (unless that is of course you have wrong information written down). This to me looks like it will change how we work! - I see it as the biggest change in the workforce potentially since I was born, we’re going from people needing to understand computers to do their jobs, to computers understanding humans, and aiding them in doing their job more efficiently, this is a whole new world! I’m really looking forward to this one! TL;DR M365 continues to evolve, and we keep seeing more and more awesome features coming from small efficiency improvements to the M365 Copilot that might very well change the way we work over the next few years! - the future is now, and it’s on M365! Also keep your eyes out for what’s to come from the Microsoft 365 Conference in the beginning of may! - I Won’t get to be there, but I have no doubt we’ll see a bunch of new and exciting things being announced, and or released! --- ### Automating with SharePoint list events Forfatter: Dan Toft Dato: 2023-03-17 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-03-sharepoint-list-events/ Automating lists If you’ve been working with SharePoint lists you’ve inevitably run into the need to know when an item/document has been created or edited, or something else. There’s multiple ways to achieve this, in this post I’ll go over some of the options that can enable us to do this, weigh the pros and cons of each, so you can take the best possible decision for your project! List rules Rules are perhaps the newest take on automating lists, they’re easy for an end user to setup, all it takes is going to the list you want to automate and clicking through a quick wizard List rules are super great because they allow you to really quickly setup a rule, and have a super intuitive UI, however they’re greatly limited in terms of what they can do, it’s pretty much limited to when x changes (and y == z) send and email to so use cases could include stuff like a task list where the assignee might change and need an email - another downside is that there’s no way to change the email that’s being sent from, and up until recently it was just sent from a random Microsoft account, now recently Microsoft announced that they would change this so that the user who last changed the rule will be the sender, this is not necessarily great either, as you’re now suddenly sending emails without knowing it. - an option to customize the email would be really nice, maybe that’s coming in the future. Power Automate (Logic apps) The next easiest way to get going is with Power Automate, Microsoft’s low code automation tool, when you go to create a instant cloud flow you’ll have a bunch of options, and more are coming often! Now with Power Automate your options are essentially unlimited, with like 900+ of connectors the limit really is your imagination, from sending emails to posting to twitter, anything is possible. Honestly this is most likely the best option to what you’re trying to do, with two major exceptions: Provisioned sites - one of the things that’re really frustrating with the SharePoint triggers is that they’re all based on a single list, I haven’t found an efficient way to use flows distributed, some people achieve this with a column formatter, this is an alright solution, but it requires the user to press a button, so I’m not really counting it here. The other is the trigger delay, if you’re running a “free” tier flow, it seems to be delayed by 1-5 minutes, this goes down to seconds if you’re on premium, so if speed is important to you, you really need to be on premium. Now this is not too much of an issue, but more something to be aware of in case you’re going back and doing something to the list item, you might run into a situation where the item has already changed again! - so please go for premium if speed is important. Websockets The first pro-code option we’ll look at is websockets, websockets are super awesome because they provide an very much instant notification when a change happens, this can client side be done with the @microsoft/sp-list-subscription, but should also be possible from daemon code, unfortunately with a websocket the only notification you get is “something changed” - not anything in regards to which item, this means you can hook it up to a list and display the lists data somewhere else and you’ll know immediately if something in the lists changes, unfortunately you’ll just have to load the lists data to see what. I see this mainly useful for dashboards since we don’t know which item changed, I’ve only used this once in production and that was a summary web part that displayed some real time stats based on a SharePoint list, unfortunately I ended up switching up this model due to a bug that now appears to be resolved, but I don’t see this a great option due simply to the fact that you don’t know what changed, also server to server websocket connections are fragile in my experience. Webhooks Webhooks are probably the solution Microsoft really wants you to use! - they provide a wide range of events from the list - ranging from new attachments to item moved, this is great because unlike with the websocket you actually know what happened, but at the same time the webhook does not contain compromising data as you’ll only get a reference to what site, list and item that have changed, so it’s up to you to find the new and old values for instance. Creating a webhook is a bit more cumbersome than most of the other methods discussed here, but once you’ve done it a few times it’s easy enough, there are however more than a few disadvantages to webhooks, some of which I think Microsoft really needs to address at some point. My main problems are best summed up here: They’re pretty slow to fire, like 1-5 minutes, timing seems random They work as a subscription - which means you’ll need to “renew” them at least every 180 days, if you’re running a provisioning scenario where you might have 1000+ sites with lists you need to be subscribed to this quickly becomes an overhead You have to respond with an OK http message within 5 seconds, essentially rendering any non always-on hosting option useless If your backend fails too many times in a given period the webhook will be removed Now this does not mean you shouldn’t use webhooks, they’re still great, and definitely the way you should move, also to be somewhat future proof, especially compared the next option we’ll look at that does all of the above, (and IMO does it better), the main reason I end up not using webhooks is simply the set up and maintenance that comes with them. But that’s not to say that webhooks don’t have their advantages, one of those being that it’s (AFAIK) the only one of the tools listed here that have a built in retry logic, meaning that if your backend code is unavailable SharePoint will retry in order to ensure that you get the notification - this is really a game changer compared to the other options! Another huge benefit is the security aspect, with a webhook you get the bare minimum amount of data to know what has changed, compared to the next option we’ll look at this is way more secure! Remote event receivers (ReR) Perhaps the oldest option on this list (I did skip Workflows since I pretty much only work with SPO) is ReRs, they’re still my most used option for a few reasons, which frustrates me because they’re very much a legacy technology, they require an ACS app registration to setup (or a user, but please never do that, it’ll be tied to that account and die with the account) - ReRs have some advantages compared to the above options, perhaps my favorite is the synchronous option, this means we have the option to intercept the create/edit event, and even more powerfully we have an option to block them! This means you can setup joined unique values, in cases where you want there to only be one item with a given set of values, or you want to make sure people actually filled out a field with more than just a “.” - another usage is syncing real time to another service. Another really cool thing about ReRs is how fast even the async operations are triggered, we’re talking less then 2 seconds from item created till you receive your SOAP request … you read that right, now luckily there are plenty of good XML to C# tools online, or even built right into Visual Studio so you can easily parse the request and use it in anything you want - I mostly go for Azure Functions. Now unlike the above options with ReRs you actually get to know more info then just “something changed”, or “this item changed” - we get a full list of the changed properties (slightly different behavior in libraries and lists, see The ReR properties table at the bottom of this page) this means you can track how the users have changed data, even without looking in the version history, but also that there’s data from your list potentially going to third parties if you don’t know where your ReR is pointing to! Another great thing about ReRs are that they’re dead simple in their implementation, simply provide a URL, and you’re off to the races, no need to validate the URL, or anything, and no required response, it’s just off to the races, this also means they’re VERY MUCH fire and forget, and if your backend is down you WILL loose requests, so proceed with caution!. TL;DR List rules are great for informing users something changed Power Automate is the perfect low code option to know when things change Websockets are best used when you would normally pull the data every few seconds Webhooks are the way to go if you’re developing a solution today! Remote event receivers are the legacy tech that really needs a modernizations (please Microsoft) Remote event receivers properties table Full credit to this page for building the lists, I’ve seen them around the web for years, but my bookmark had died when I was writing this post, so here’s another backup List BeforeProperties AfterProperties properties.ListItem ItemAdding No value New value Null ItemAdded No value New value New value ItemUpdating No value Changed value Original value ItemUpdated No value Changed value Changed value ItemDeleting No value No value Original value ItemDeleted No value No value Null Library BeforeProperties AfterProperties properties.ListItem ItemAdding No value No value Null ItemAdded No value No value New value ItemUpdating Original value Changed value Original value ItemUpdated Original value Changed value Changed value ItemDeleting No value No value Original value ItemDeleted No value No value Null --- ### 🐫(CAML) Query: What is it and why is it relevant in 2023? Forfatter: Dan Toft Dato: 2023-03-07 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-03-caml-query-in-2023/ Why this post Recently I had the great pleasure of presenting a sample web part CAML to Table on a PnP Community call, during that presentation something occurred to me from the chat: Not everyone know what CAML is! Now, not only did that make my presentation confusing to some, it also means they’re missing out on some great opportunities. So what is CAML? CAML is short for Collaborative Application Markup Language and is an XML based syntax to query data in SharePoint (Microsoft) Lists. CAML has been around for at least 10 years at this point, at might have a bit of a “old ring” to it because of it’s XML nature, but it’s very much hidden right there in plain sight - every time you create a view, filter a list, or even sort a list, it’s all CAML, it’s just hidden so you don’t have to worry about is. Traditionally CAML has only been something for coders to worry about, but I genuinely believe that if you’re doing anything with lists you should at least look at the basics of it, because it can really help you present the data you want to show, and hide the rest! Getting started The easiest way to get started, is to find a list, setup a view with a simple filter - connect to the site with PnP.PowerShell and run the following two lines to see the query of the view you’ve just created $view = Get-PnPView -List "Onboarding tasks" -Identity "Done Tasks" -Includes ViewQuery Write-Host $view.ViewQuery The output will be in a single line, but using a tool like VS Code to format it you’ll notice it’s just XML Done Looking at a simple CAML query it’s actually somewhat readable, WHERE is equal to Now this query is somewhat simple, but the great part about writing CAML query is that we get to mix and match our ‘ANDs’ and ‘ORs’ and get creative really fast, let’s say we want all the Stevens, no matter their age, and Dans that are over 20 and under 30: Steven Dan 20 30 Now if you’re anything like me you can look at that and get an idea of what’s going on, but writing it … no thanks! Here we’re incredibly lucky that the community has our back! There are two libraries you should know about: camljs by Andrei Markeev is a great JS library that allows you to write CAML queries in a more fluent style - there’s also a great browser extension also by Andrei, that wraps the library in an easy to use way. Here is the same query from above but written in camljs var query = new CamlBuilder().Query().Where() .TextField("Name").EqualTo("Steven").Or() .TextField("Name").EqualTo("Dan").And() .NumberField("Age").GreaterThan(20).And() .NumberField("Age").LessThan(30).ToString(); Now if you’re writing .NET, there’s also a library, Camlex.NET by Alexey Sadomov - which uses LINQ to setup the filters for your query, like here: Camlex.Query().Where(x => (string)x["Name"] == "Steven" || (string)x["Name"] == "Dan" && ((double)x["Age"] > 20,0 && (double)x["Age"] < 30,0)) Using the CAML query Alright, so now you’ve created an awesome query, and maybe even tested it with the CAML2Table sample I mentioned earlier, now what can we do with it? - well CAML is super awesome because it’s the tech behind views, this means we can set a views filter to a CAML query Beware that this well reset if someone changes the filter and saves the view. Setting the filter is done with PnP.PowerShell like this Set-PnPView -List "People" -Identity "Stevens and Dans in their 20s" -Values @{ViewQuery = "StevenDan2030"} Notice we removed the and CAML vs. SP REST Now if you’ve worked with REST and OData you might be thinking “I can do all of that with the REST API easier” - whether it is easier or not I’ll leave up to you, but, there are a few REALLY cool tricks we can do with CAML, that cannot be done with REST The IN statement Have you ever seen something like this Name eq 'Dan' or Name eq 'Steven' or Name eq 'Brian'.... CAML makes this really easy - with up to 1000 values in your filter (There are workarounds to that limit as well) Dan Steven Brian ... Membership operator This one is REALLY powerful - have you ever had a task list in SharePoint with an assignee field you might’ve had that case where a task needs to be assigned to a group, now using a SharePoint Group and a person field that allows groups we can use the Membership operator to filter based on if the current user is in that group using the CurrentUserGroups filter - this way we can have a shared task list- Next level is hiding the tasks that are done, and just like that you have a list of tasks that are pending pr. department Done Querying a URL field based on the URL This one is pretty self explanatory, but is actually not supported in the SharePoint REST API, querying a list item based on the value of a URL field https://pnp.github.io/blog/ Summary That was a couple of cool things that CAML allows you to do that can’t easily be done with anything else, there’s also awesome support for date filters, including offsets from “Now” that make it really easy to write a query that’ll always be up to date. The only thing I find myself really missing coming from SQL is the ability to select an item based on the value of two columns relative to each other, i.e Where Modified By and Author aren’t the same, this can however be worked around using a calculated column and querying on that TL;DR CAML Has been around for a really long time, and while it’s not as visible as it’s been previously, it’s not going anywhere anytime soon, so you might as well start learning it today. More Resources Read more about CAML Query schema CAML to Table sample to test queries --- ### Protecting your Azure functions using your AAD Forfatter: Dan Toft Dato: 2023-03-01 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-03-protecting-azure-functions-aad/ Full disclosure before you read this blog post, this will be a lightweight introduction to the capabilities of authentication for your Azure Functions provided by Microsoft, I will not go into what is going on behind the scenes, how the authentication works, this will be a “implement security 101” - the baseline of what you need to get going building protected functions! What we’ll be protecting The function we’ll be using in this example is just a simplified version of the default visual studio boilerplate code where I’ve stripped out http post support, just for the sake of simplicity. [FunctionName("HelloWorld")] public static async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log) { string name = req.Query["name"]; string responseMessage = string.IsNullOrEmpty(name) ? "Pass a name in the query string or in the request body for a personalized response." : $"Hello, {name}. This HTTP triggered function executed successfully."; return new OkObjectResult(responseMessage); } Why security is important Now with the function above you might be thinking “why would I want to protect that?” - well, it’s a sample, nothing more. But from time to time I find myself building functions that are connected to databases containing sensitive information, or elevating user permissions, like getting access to specific parts of the datasets that the user does not normally have access to, or maybe you just want to make sure the user calling your function is already authenticated. But security is hard! Well yes and no, coding security is generally considered to be tough, but thankfully Microsoft has provided a no-code way to setup authentication, it’s actually all done through the Azure Portal (or better yet through your terminal) Setting it up Currently my function works like this GET | https://yourazurefunctiontosecure.azurewebsites.net/api/HelloWorld?name=Dan 200 OK | Hello, Dan. This HTTP triggered function executed successfully. Now to add authentication we’ll open up our function the Azure portal, head to the authentication tab, and press the “Add an identity provider” button. You’ll be asked to select your identity provider, here we’ll select “Microsoft”, a bunch of options will appear that can be overwhelming. Now there are two options we want to change in the particular case, the rest are great in their default configuration for our purposes. Option Value Name An app registration will be created, this will be the name of said registration, I generally add something like ’ - Identity provider’ at the end Unauthenticated requests HTTP 401 Unauthorized: recommended for APIs Press Add Calling your function now should result in an HTTP 401 | You do not have permission to view this directory or page. Awesome, that’s it, now we’re done, your function is secure and no one can call it, perfect! … jk - let’s look at how we can call it. Calling your function from SPFx So firstly you’ll need to get API permissions for your SPFx solutions, if you haven’t done this before this is done by updating your ‘package-solution.json’ and adding the webApiPermissionRequests section: ... "webApiPermissionRequests": [ { "resource": "YourAzureFunctionToSecure - Identity provider", //This is the name of the app registration that was created automatically in the previous step "scope": "user_impersonation" } ] ... Now the next time you deploy your SPFx solution (to the modern app catalog) you’ll be prompted to approve these permissions - you’ll need an admin account. Unfortunately this still isn’t enough, but Microsoft has done a really good job at making it easy for us to authenticate from SPFx, with your component context (WebPartContext, or alike) - import { AadHttpClient } from "@microsoft/sp-http"; ... ... const client = await context.aadHttpClientFactory.getClient(CLIENT_ID); ... const URL = "https://yourazurefunctiontosecure.azurewebsites.net/api/HelloWorld?Name=Dan"; const response = await client.get(URL, AadHttpClient.configurations.v1); ... Now the CLIENT_ID parameter here is the client id of the corresponding app registration that you’ve requested permissions from above, it’ll be listed under the Authentication tab on your function. The rest should look pretty familiar to you if you’ve ever call the SharePoint REST API from SPFx without using libraries like PnPjs. And there you go - you’re off to the races! Calling your function from another application using OAuth 2.0 We’ll be using Postman in this example, but it should be essentially the same procedure to do it from C# using the ConfidentialClientApplicationBuilder, or whatever language you’re using Now, when using SPFx as in the sample above, there’s a user present, but when using OAuth from “the outside” we won’t have that privilege. So in order to setup authentication from elsewhere, the first thing we’ll setup is a “consumer” app registration, simply create an app registration and name it whatever makes sense to you. AAD -> App Registrations -> New registration We’ll just fill out a name and leave the rest alone Once that’s done we’ll need to grant our app registration permissions to call our function, this is done by going under your app registration going to API permissions -> Add a permission -> My APIs -> Select your identity provider -> user_impersonation -> Add permissions The setup we want to see Now go ahead and click Grant admin consent for {your tenant name} Now the next step is to go to Certificates & secrets and depending on your authentication needs add a secret or a certificate. For this test purpose I’ll use a secret, I would recommend certificates in actual production environments. That’s all, now you’re ready to call your function again! Let’s test is out from Postman: Postman config - grab these value from the overview of our two app registrations Setting Value Access Token URL https://login.microsoftonline.com/{{Tenant ID}}/oauth2/v2.0/token Client ID {{Client Id of our consumer app registration}} Client Secret {{The secret you generated}} Scope {{Client ID of your identity provider}}/.default Client Authentication Send client credentials in body Success, that’s all it took!, now go do it See who’s calling (C#) Now you might want to know who’s calling your function, this can be achieved pretty simply by adding a ClaimsPrincipal parameter to your function, the following snippet should give you an idea of how to look for the callers identity [FunctionName("Me")] public static async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = null)] HttpRequest req, ILogger log, ClaimsPrincipal claims) { ... Dictionary claims = new Dictionary(); foreach (Claim claim in Principal.Claims) { log.LogInformation("CLAIM TYPE: " + claim.Type + "; CLAIM VALUE: " + claim.Value); claims.Add(claim.Type, claim.Value); } .... return new OkObjectResult(claims); } For users calling from SPFx I generally go for one of the following properties (sometimes one is null and another is not, i.e if the user doesn’t have an mailbox or something like that) http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn preferred_username You also always for the object id, what should work across both App registrations and Users, depending on your needs TL;DR Adding authentication to your function is far easier than you might think, and there really isn’t any excuse not to do so. And do it NOW, before it’s too late! If you had any issues following this guide feel free to reach out to me over on twitter and I’ll be happy to help. All functions, and app registrations used in this example have been deleted, you can’t test with them --- ### Attending your first conference - how and what to do? Forfatter: Dan Toft Dato: 2023-02-21 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-02-attending-tech-conference/ I’m currently sat in my sofa looking through the recently published agenda for the European Collaboration Summit 2023 as I recently convinced my boss to let me attend - this will be my second tech conference, and I realized there was something I missed before I headed out to my first conference - “a beginners guide to tech conferences”, now normally I would say that a rookie can’t write a beginners guide, but maybe in this case there’s an exception! - here are my thoughts, takeaways, tips and tricks I wish I knew before my first conference! My first conference As someone who started out their career in 2018/19 Covid has been a thing for most of my “adult life” watching conferences remote, I was supposed to go to my first conference back in 2020, but yeah… 2022 would be the year I finally got to go, I went to the European SharePoint, Office 365 & Azure Conference (ESPC) it was being held in Copenhagen, and after begging my boss for a little while he caved in - I WAS HEADED TO ESPC! I went with a colleague who ended up getting sick while we were there, so I spent a decent part of the conference on my own, which means I had to just “figure things out” if I didn’t wanna spend the nights alone in my hotel room, here are my best takeaways from that trip. Pre-conference The “networking” part of the conference actually starts waaaay before the real conference, usually there is an app (ESPC uses Whova, Collaboration Summit uses run.events, and others probably a third one) they all serve the same purposes! Calendar See the agenda Plan your schedule Social events Networking Private messaging “Group” chats - things like ride sharing, local recommendations, ask the organizers ect. Social events Giveaways - lots and lots of lego The app is where you really want to get in on the action, there’ll be loads of chit chat, I was lucky enough to be some what local to the conference, having spent a ton of weekends in Copenhagen over the past year, so I was able to share tips on local foods, places and public transit. You might not be that lucky, but still do your very best to be active here, not only reading, contribute! - I cannot stress this enough, contribute, this means people will see your name, they’ll remember seeing you everywhere, I remember seeing Lesley Crook and Kevin McDonnell literally EVERYWHERE in the app weeks before heading out, I would later realize that others had noticed my posts as well, this started already monday night when I realized I was stranded at the hotel by myself - NO WAY, I pulled up the app, and found that there was a pub quiz at a local bar that night! - unfortunately it was fully booked, but I ended up sending a message to the host and getting a ticket, and several people there already had seen me on the app and “knew” me, which was great fun, and also a great conversation starter, and a way to make sure people remember your name. Also beware that usually there’s an option to pick-up tickets the afternoon before your ticket is valid (as least for multi day conferences) - this will save you time in the morning where you might already be stressing a ton - and there might be queues! What to pack You’ll of course want to pack some clothing for the days you’re gone, go with whatever you’re comfortable in, doesn’t matter if thats a shirt, hoodie, or even just a t-shirt, no one is judging you - also make sure you pack some solid shoes, you’ll be walking a ton! A few other things I had great pleasure from bringing Earplugs for sleeping in a potentially noisy hotel. Medication, especially some painkillers, you’re most likely going to be hungover at one point or another. Snacks to carry in your bag, there’s probably food at the venue, but a granola bar is nice cause you can enjoy it anywhere. A water bottle, remember to stay hydrated. Powerbank, you’re going to be spending all day out, and perhaps using navigation and taking photos. Pen and paper, remember to take notes! A daypack to bring to the venue to carry all the swag you’re going to be collecting. During the day If you’re like me you’re excited about heading to a conference to finally meet people who are as passionate about tech as you are, and I guarantee you’ll meet people who are waaaay more passionate, smart and brilliant than you, be sure to listen to them, suck in all the knowledge you can! I cannot stress this enough! - not only the speakers, also the peers in the crowd, talk to the person next to you, go to the “ask the experts” panels, if you don’t feel like asking questions, hang around even just to listen, there is so much knowledge “in the air”. When attending the actual sessions, don’t be afraid to ask questions at the end, if you’re like me and slightly introverted you might be more comfortable going up and talking to the speakers afterwards, and that’s also totally okay, speakers are just like you, they’re passionate techies. Between sessions remember to check out the expo area, where you’ll be able to find a bunch of sponsors presenting their products, for instance talking to a rep from one of the sponsors, and you’ll be able to collect swag, this ranges from stickers to t-shirts in return they scan your pass and get to send you some marketing material afterwards. Another tip I cannot stress enough, bring a pen and paper, looking out over a group of attendees at a session where I arrived a bit late and took a seat in the back I noticed that thing we all know - people on their laptops get distracted, Oooh, look an email… - pen and paper is the only 100% distraction free note taking option, and note taking is something you might as well do from the get go, you’ll gain sooo much knowledge during those few days, 3 days at ESPC (18 hours of sessions) ended up in 27 pages of notes for me. During the afternoon/night As soon as the sessions end the expo area starts to live up again, and you’re going to wanna hang around there to grab a drink from some of the sponsors. Here you might be tempted to head off with a colleague for a bite in town and then back to the hotel for some much needed sleep, BUT you would be missing out on half the fun! No matter if you’re into parties with unlimited drinks, exploring the city, or even just meeting new people over a bite of local food, make sure to checkout the conference app, here you will find all of the above and sooo much more! - and don’t stress, you won’t be able to do it all, so pick and choose some of the things you find interesting. I had a blast heading to Tivoli gardens with a bunch of people I’d never met before, a lot of them I knew either from seeing them on twitter, listening to their podcasts, or even just from the conference app - initially I was obviously a bit “scared” as these were people who had proven their worth in the community, many where even MVPs, and most certainly had no clue who I was, but wouldn’t you know it, they were all super awesome and welcoming people, remember MVPs are also just passionate techies, and we ended up having a blast exploring Tivoli at later heading to a great party by one of the conference sponsors. Photo credit - Kevin McDonnell (@kevmcdonk) This also gives you a great opportunity to talk to speakers, I ended up changing my agenda after talking to some speakers about their upcoming sessions, and hearing what they were planning to cover, or weren’t - this is a great way to get a feeling for the level of a session before heading to it. Make sure you remember yourself and your body during the week - I ended up sleeping an average of 3 hours a night for the days of ESPC, man was that rough, but I had an absolute blast, ended up spending a full week recovering fully, always remember it’s better to miss part of the party and feel good rather then ending up staying in bed a full day. Post conference Now during the conference you’ll meet a bunch of people, make sure you connect with them on your preferred networking app, whether that would be Twitter, LinkedIn, or even mastodon, that way you’ll be able to keep up with what your new friends are up to, and expand your network! The next thing that might be a good idea to do is to create a presentation that you can share with your colleagues that didn’t get to go, this can either just be in the form of a news post on your intranet, or some slides that you present for them at a “mini-session”, I really like the latter as it often leads to there being some chatting around some of the news that you’re “bringing home”. TL;DR Bring yourself, your best mood, and make sure to enjoy the moment, have a great time, and talk to strangers! After I introduced myself to some new people as “a nobody” Kevin pulled me aside and said something that stuck with me - and really helped me realize, it’s okay to go up to anyone and talk to them! You’re not a nobody, your just building you career! – Kevin McDonnell P.S. Go read Kevins awesome post Chatting is hard but so so worth it --- ### Planner Application permissions - what does it mean? Forfatter: Dan Toft Dato: 2023-02-04 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-02-planner-application-permissions/ The why Planner is one of those products that are really awesome, but it is also very hard to keep structured due to the lack of templating/automation around it, this leaves every Planner board at the mercy of users reproducing an existing setup again, and again … that’s not going to happen. The what So, what does application permissions for Planner mean? - well essentially it means that we can now access Planner from automated code, this makes things like provisioning Planner boards possible. So, what can we use it for? - well essentially we can do anything you normally can do in Planner, but automated! For instance, a thing I often meet in the market is people who’re used to Trello but want to migrate to Planner, not only can we now actually migrate their existing tasks to Planner without every task being created by a service account, we can also automate certain tasks, or pull data for better reporting, and all pretty simple. Real life business cases In the half a month or so this API has been available I’ve already used it a few times to do some cool stuff, here are some real-life ideas that can be implemented. Provisioning tasks Some business scenarios have us provisioning teams or sites for every project in the organization, this is nothing new, but what we’re now able to do that is new is provisioning and assigning tasks upon project creation - and even project phase changes, I’ve implemented this combined with a stage-gate model where users cannot change the project phase without having completed all the tasks in the current phase, and then when they move to the next phase new tasks are created fully automatically, also tasks are dynamically assigned based on your role in the project. Connecting tasks to Outlook events An other use case that I found really interesting was a client who has 600+ meetings a year, had a hard time keeping up on pre and post meeting tasks, they had tried Planner, but found it hard to keep things in sync when a meeting moved date suddenly you have to remember to move the tasks in Planner as well, otherwise your to to-do would remind you to “send out meeting summary” for a meeting you haven’t yet had. So, I built a relatively simple job that’ll generate a few tasks in Planner for each meeting in a specific calendar and move them with the meeting in Outlook keeping it very simple to keep on top of your meetings and tasks that go along with them. The how Microsoft introduced a new permissions scope Tasks.ReadWrite.All unfortunately there’s currently no way to limit this down to a single group/board at this time, maybe we’ll see that down the line, just like we did with the SharePoint application permissions, maybe even a read-only permission for reporting reasons. So, once you’ve granted yourself this magical new permission what’s next? Well because this API has been supported for a while using delegated permissions it’s already in all the SDKs, so simply authenticate with your app-registration and start using the API Here is an example using the GraphServiceClient in C# PlannerTask plannerTask = new PlannerTask { PlanId = Desired_Plan_Id, BucketId = Desired_Bucket_Id, Title = "Create my first task via the API", Assignments = new PlannerAssignments { AdditionalData = new Dictionary() { {AAD_Object_Id_of_user_to_assign, "{\"@odata.type\":\"#microsoft.graph.plannerAssignment\",\"orderHint\":\" !\"}"} } } }; await Graph.Planner.Tasks.Request().AddAsync(plannerTask); Quirks There were a few things that I had to wrap my head around, at least with the C# SDK, which is the main way I’ve been integrating with the new permissions. Task assignments First off, why are user assignments so hard! - Why do I need to remember the JSON parameter in order to assign a task! - this should really just be handled by the SDK where I just provide a list of user IDs, I get that it’s stored like this in the backend, but I don’t need to see it on the dev side. {"@odata.type":"#microsoft.graph.plannerAssignment","orderHint":" !"} The API structure Another one of those where the Planner API is not structured hierarchically like (at least I) expected. This means that you go. await Graph.Planner.Buckets[BUCKET_ID].Tasks().Request().GetAsync(); and not hierarchically like we’re used to from other Graph endpoints, or SharePoint which would be more like: await Graph.Planner.Plans[PLAN_ID].Buckets[BUCKET_ID].Tasks[TASK_ID].Request().GetAsync(); E-tag Another thing I’ve found which feels like one of those growing pains where it becomes obvious that the C# SDK at least hasn’t been used widely for Planner compared to things like PnPjs which supports the delegated permissions and has been widely used for years is the E-tag property you need to grab when you’re updating the task you’ve just created. Not only to you need to grab it from the AdditionalData you also need to strip off the first two characters, this hopefully will be handled by the SDK at some point. PlannerTask PlannerTask = await Graph.Planner.Tasks.Request().AddAsync(plannerTask); //We have to remove the 'W/' at the beginning of the E-Tag //https://powerusers.microsoft.com/t5/Using-Connectors/Custom-Connector-Graph-The-If-Match-header-must-be-specified-for/m-p/144099#M3314 string ETAG = PlannerTask.AdditionalData.TryGet("@odata.etag") + "").Substring(2); await Graph.Planner.Tasks[PlannerTask.Id].Request().Header("If-Match", ETAG).UpdateAsync(new PlannerTask() { Title = UPDATE_TITLE }); Other SDKs like PnPjs handles this smoother, that makes it obvious to the developer that you need to provide an etag const UpdatedTask = await graph.planner.tasks.getById(TASK_ID).update({properties, eTag: ETAG}); TL;DR The Application permissions have finally landed, and they’re awesome! I’ve played with the C# Graph SDK and it works great, there are a few quirks that need some ironing out before it’s intuitive, Microsoft should really look towards the community and PnPjs for some inspiration on how to handle certain things more intuitively - but they’re here, and they work Bonus tip: what does application permissions mean? This is one of those places where we as dev sometimes take things for granted, and assume that everyone knows what things are, but the essence is that application permissions is the way our code can authenticate without a user being present - where as delegated permissions are the ones where we extend the currently signed in user. --- ### January 2023 in M365 Forfatter: Dan Toft Dato: 2023-01-31 URL: https://pinksky.dk/blog/forfatter/dan-toft/2023-01-january-2023-m365/ The death of stream Well - sort of, as you may have noticed, Microsoft is pushing Stream on SharePoint pretty hard! - this also means the death of classic stream as we’ve come to know and love (or hate) it. This also means more features coming to SharePoint, this includes an option to create playlists of multiple videos, for instance if you’re building a learning session consisting of multiple videos. Microsoft unveiled some quite important “save the dates” - as we now have a schedule for how and when stream is going away - it’ll be a three-phase process. May 15th - You’ll no longer be able to upload new content to stream. October 15th - This is the “official” death date, stream will close, and users will no longer be able to access their content. - this is when the phone starts ringing, and you’ll be able to enable extended support. February 15th ‘24 - This is the “real” death, on this day ALL your content in stream disappears … forever. BEWARE - It is your own responsibility to migrate from Classic stream to Stream on SharePoint, whether this is a cool play by Microsoft or not, I won’t comment on, but you really need to start thinking and planning about how you’re going to migrate if your org is using stream. Read more here Planner Application Permissions This is a small one if you’re not a dev, but for us devs, it’s huge - back in October Microsoft promised us Planner application permissions, they’ve now landed, what this means is that we’re now able to support Planner in automated workflows without needing to use a service principal, but can instead use our normal app-registrations, this is huge, both for the accessibility and security, and makes supporting business scenarios in Planner a lot smoother and easier. Office scripts If you’ve been around for a little while, you might have noticed just how hard Microsoft has worked to kill macros, and while nothing will stop that old script guy in the corner, they’ve now launched the first real alternative … or well, it’s made it to the desktop, it’s been in the cloud for quite some time. What is Office scripts? - Office scripts is a way to run Java(Type)script inside your Office apps, currently limited to Excel. It provides a great API to interact and automate parts of your spreadsheet - that could be counting and adding up in ways you normally couldn’t do in excel, or - and this is where it gets better then macros for me, you can run you scripts from Power Automate, so you could have a spreadsheet with everyone’s contact info and look up peoples phone number from Power Automate (please don’t actually do that, it’s a terrible way to structure that info, just using it as an example) The death of Teams Wiki pages If you’re one of the dozen or so people left on earth who still uses Teams Wikis, it time to act NOW - from mid-February they’ll users will be unable to create new wikis, an option will be provided to export your existing wikis to OneNote, and instead use a OneNote tab in teams. This change makes a ton of sense given the relative feature parity between the two products, and OneNotes superior search experience (and indexing in Microsoft Search) See MC496248 for more info Update to Microsoft/SharePoint lists Ever since the “launch” of Microsoft lists, we’ve seen a ton of love for them, and for the past year Microsoft has been pumping out new features, they currently show no sign of slowing down anytime soon! - we’ve seen a few new features already, and a ton more are just around the corner, this includes: Calendar view - week mode (coming soon) Generate a list from CSV, this feature launched and is working awesomely This provides a great way to import data from an older system you have and surface it in your SharePoint environment for all users to take advantage off - perhaps that old contact list form earlier! Additional lookup field options Lookup to boolean & multi choice fields. Column formatting to show up on lookup fields (this is MEGA) These updates were announced at Ignite, but are starting to show up now. TL;DR The platform you’re on is constantly evolving, beware that Stream and teams wikis are going away, and lists are getting even more awesome! --- ### Viva is coming home for christmas (almost) Forfatter: Dan Toft Dato: 2022-12-19 URL: https://pinksky.dk/blog/forfatter/dan-toft/2022-12-viva-coming-home-christmas/ Viva connections If you’re like me you were excited when Microsoft launched Viva connections - a way to get your SharePoint Online intranet integrated into Microsoft teams! - lovely, especially since we’re trying to work more and more from within teams. But the viva branding never quite clicked for me, no additional license, no viva branding, it was just simply your SharePoint Home site inside teams. I’ve been very happy with the feature as it was, and it’s been an easy “let’s also do that” for every client that I’ve built an intranet for over the past year or so! - but that’s all about to change! The new Viva Connections home experience In case you haven’t noticed, Microsoft is pushing viva, and that’s a train that doesn’t seem to be slowing down any time soon, with that Microsoft recently announced that they’ll be “updating” the viva connections app in the spring of 2023 (Roadmap ID 98173). What does these update include? - well first of all a more viva centric experience, whether or not you like the new experience might be a personal preference, I’m not going to tell you to love it, or hate it. I’ll say that it does look quite different from what we’re used to seeing in SharePoint, but I think it’s just a matter of getting used to it! - One thing that I really like is that I think we’ll see more coherent designs than what I’ve at least seen some SharePoint intranets look like. Here are the two compared: One thing to note is that at the time the only real way to extend Viva connections home is by using SPFx Adaptive Card Extensions, again a more coherent experience, instead of a ton of custom webparts. What does it bring? The new viva connections home will bring insights into the users viva data, so if you’re already invested in the viva platform this will be a very welcome addition! Navigational elements At the very top there’ll be a navigational bar connected to your home site, and the other viva apps. Dashboard If you’re not that invested in viva this is where things will be a bit annoying, because what you’ll see at the top is your viva dashboard, that you might already be familiar with from your current home site, if you haven’t already set one up, now might be a good time to get started on doing just that, as it’ll be a key part of the experience! Resources Next below that you’ll find your resources, this will show your navigation from your app bar, and some frequent data (right now it looks like it’s going to be Frequent sites and Followed sites) Feed To the right of all of that is your feed your feed is a rollup of news, from your org (Home sites, news sites), Yammer (Viva engage) posts, that are relevant to you (organization community, and communities you follow) Is my home site going away? Don’t worry, your existing SharePoint home site is only a single click away, just click the button in the top right to head straight there, if your organization decides not to adopt viva there will be a PowerShell cmdlet to disable the new experience! (maybe only if you’re already using viva connections now, I haven’t been able to find a clear answer on future customers) What about my theming, my organizations branding is important to us Don’t worry there will be plenty of customization options, I haven’t seen anyone mention the color scheme, but the banner image is customizable, and I’m assuming we’ll see even more in the not too distant future. Final thoughts This will help bring even more people onto viva connections, as it’ll be easier than ever to setup, and what’s awesome is that it’s a great way to get users familiar with the viva look and feel! But I still foresee a lot of client’s wanting the default experience changed back, but we’ve been promised that we can do that, so no worries. I would rather have seen this as a new product (viva home) so I don’t have to explain to people that viva connections is now defaulted to this new viva connections home, and not their intranet that they might have built over years. TL;DR The Viva connections app’s default layout will change in the spring of ‘23 and get a lot more viva centric. You’ll be able to switch the new experience off for existing environments, no word yet (that I can find) on new environments. Read more Roadmap entry More options coming soon for the Viva Connections desktop experience Overview of Viva Connections --- ### When to use Add shortcut to OneDrive vs. Sync Forfatter: Dan Toft Dato: 2022-12-12 URL: https://pinksky.dk/blog/forfatter/dan-toft/2022-12-add-shortcut-onedrive-sync/ What does the new “Add shortcut to OneDrive” button do? So recently Microsoft added the new “Add shortcut to OneDrive” button, and that has caused a few confused emails from people asking which to choose, and what the difference is. And to be honest I had no clue, so I recently decided to figure it out, to my luck Microsofts own podcast The Intrazone released an episode title “Add to OneDrive” - in which they brought on Gaia Carini (Principal GPM) and Katy Erlandson (Senior product manager) to talk about this exact issue - I honestly recommend you just go and listen to that episode rather than reading this blog post, but now that you’re here I’ll go over my main take aways mixed with my personal experiences. Why Sync is frustrating Sync is one of those features I think I’ve had the most frustrating calls about in my career, not the most calls, just the most frustrating, actually to the point where I just generally recommend my clients not to use it. Now why is that? - generally because every single time, data has either been lost, or has almost been lost and requires manual remerge, because the client had gotten disconnected, or just the classic “it’s slow”. What does “Add shortcut to OneDrive” add to the table Performance Add shortcut to OneDrive does not seem to fix all of these issues, but does some work to address it, mainly the performance part, something that I did not know, but that was mentioned in the episode is that if you sync a folder from within a document library it’ll still sync metadata for the entire document library, this is actually something I’ve previously recommended to clients as a way to improve performance, and while it did help, the way “Add shortcut to OneDrive” handles it is supposedly vastly superior as it skips the metadata sync from the rest of the document library. Cross device Another issue people often have had with sync is whenever you get a new PC you would have to resync all the locations you want synced, and there also isn’t an easy way to get access to your synced files on a mobile device. Add shortcut to OneDrive solves both of these issues by simply adding a shortcut in your own OneDrive, this means it’ll be available right there on your new PC, your phone or your tablet, easy as pie. Issues that still remain I still have a few concerns before I’ll actively start recommending clients to actually use either approach to get files to their file explorer, and not just try to stay in the browser. Sync client disconnecting While the “Add shortcut to OneDrive” does also assist the users in getting easy access to files in SharePoint from their OneDrive online, the main use I see is to get the files accessible in file explorer, and I’ve seen this approach fail one too many times due to the OneDrive sync client getting logged out and the user ignoring the errors that’ll show up - and eventually they find out their colleagues cannot see their changes, and that’s when I get the frustrated call, the only real solution I’ve found to this is user training, more user training, and a bit more of user training. Delete Oh god the amount of times I’ve seen this happen… someone in the organization has synced an entire library, decided “hey, I don’t want those files to be taking up space on my machine” …. *Delete*. Cue the call “WHERE ARE MY FILES?!?!?!”, let me have a look, oh looks like John deleted them, John claims he didn’t, bit and back and forth, before everyone is caught up, and realize what happened, get the files restored, help John to remove the synced library, and we’re rolling, so long as the files are still in the recycling bin… Thankfully Katy Erlandson mentioned in the Intrazone episode that it is a top priority to make it more clear to the users what they’re doing which is awesome to hear! Final thoughts I definitely see “Add shortcut to OneDrive” as the future instead of sync, and already now is an awesome way to get your SharePoint files into a single place. But until Microsoft finds a great way to solve the issues regarding users accidentally deleting large amounts of data, and the issues with the OneDrive client getting signed out and users still being able to work for weeks until they somehow realize that that one prompt they kept closing means they haven’t been syncing changes for weeks. So the way I see it, we’re headed the right direction, and with promises being made around the delete prompts I find it hard to believe MS aren’t also working on fixing the disconnect issues as well. TL;DR Use the “Add shortcut to OneDrive” for the best experience, as files will be available on all your devices, not just the one you’re on right now, and for better performance! --- ### Run JavaScript in Power Automate Forfatter: Dan Toft Dato: 2022-12-08 URL: https://pinksky.dk/blog/forfatter/dan-toft/2022-12-run-javascript-power-automate/ Why would I ever need JS in Power Automate? Coming from a pro-code background, I’ve always found the Power Platform to be interesting and cool, but often a bit cumbersome, to say the least, I love the idea of citizen developers to be able to build tools that helps them grow their business, but I haven’t personally spend too much time with it! It wasn’t until recently where I was training a new colleague who’s new to Microsoft 365, and comes from a pro-code background that I really ended up scratching my head, and came across this feature, a customer wanted a flow that whenever a mail arrived in a shared mailbox with a subject that contained a specific pattern any attachments would be uploaded to a SharePoint document library. Oh, what a great case for Power Automate - a simple trigger on new mail, a quick RegEx, and upload attachments - should be easy … Well as it turned out after I had scoped the task and assigned in to him, there is no non-premium RegEx testers in Power Automate. Well crap! - Well, there has to be a way, quick Google search later I came across a tweet suggesting that Office Scripts could be triggered from Power Automate, NOW this opens a whole new world - cause we can now run any JavaScript code in Power Automate. The implementation What impressed me the most was just how easy it was to implement, let’s have a look! - Firstly I’m going to be assuming that you’ve got a flow already setup and am just currently stuck on implementing a little thing, so that’s where we’ll start! Since we’ll be using excel for this we’ll need to create an excel file and store it somewhere, if at all possible I would recommend a location only you have access to, that way you’re sure no one will ever come across it and end up breaking your flow - you could also slowly build your own little library over time that you copy with you from solution to solution. Once in Excel Online you should see a “new” ribbon tab called Automate (NOTE, you’ll need a Microsoft 365 license with access to the Microsoft 365 Office desktop apps - despite this not actually working in the desktop apps…) To those of you who’ve ever used macros this will look fairly familiar, and to those of you who haven’t had to deal with them … congratulation! From here we’ll click the new script button, from where we’ll have the option to create a new script, for this sample I’ll be creating a AddTwoDigits function, just to show the concept. My code ends up looking like this (I know it’s technically TypeScript, but just roll with it) function main(workbook: ExcelScript.Workbook, num1: number, num2: number): number { return num1 + num2; } You’ll notice that our first parameter “workbook” is of the type ExcelScript.Workbook - this is a JS reference to the spreadsheet if you wish to read/write to the sheet, for our case here I’ve just added two more parameters, the two digits we want to add together, and a very simple return. It looks like this in practice From here it’s as easy as pie to use it in Power Automate, simply add the “Run Script” trigger from Excel in Power Automate And after running the flow this is what we get There you go - You’ve just successfully ran JavaScript/TypeScript code from Power Automate! Notice the “logs” array? - that’s a string array of all the console.log’s you’ve printed, prefixed with a timestamp, like below: "logs": [ "[2022-12-09T19:04:17.9850Z] Adding 1 and 2", "[2022-12-09T19:04:18.0010Z] The result is 3" ] If you’re anything like me you’re about to attempt to use the fetch API this way to avoid the premium connector, unfortunately Microsoft already thought of this, so no dice. Conclusions This is a really cool technology, and really highlights some of the crazy ways you make Microsoft’s different technologies play together, and I absolutely love it, and since discovering it, I’ve used it a few times to help out my low-code colleagues, when they hit a roadblock or something (like RegEx) that isn’t really supported in Power Automate, however it’s a fairly vulnerable and obfuscated way to solve a simple problem, I wish Microsoft would bring the JavaScript action from Logic Apps to Power Automate, and at the same time I get why they haven’t. For me the biggest problem with this approach is how easily it breaks if someone somehow gets rid of you excel sheet, i.e IT deletes the site it’s on cause of inactivity, a user deletes it cause “no one is using it” - and at least for me the OneDrive “Location” option was broken - that might also just be my dev tenant, I’m not quite sure. TL;DR You can use Excel Script to run JavaScript in Power Automate. --- ### Sites.Selected Permissions what is it, and how do I use it Forfatter: Dan Toft Dato: 2022-12-07 URL: https://pinksky.dk/blog/forfatter/dan-toft/2022-12-sites-selected-permissions/ Why is it even needed For the longest time we were using service accounts when making background jobs to run on SharePoint online, which overall was… let’s call it functional, then we moved to App Registrations with Client Secrets which were just as simple to implement as the service accounts, awesome - however we could only do two permission levels Read everything Read/Write everything Instead of the app just having the same permissions as the service accounts. This (at least for me) caused a lot of concerned clients, (in fact this reddit post is a great example of just that, and is what prompted me to write this post). So initially we moved to using X.509 certificates, as that was a bit more secure, and “the best we can do” - now as time went on, more and more clients started to bug us about this, and FINALLY - in back in February of 2021, Microsoft announced a new permission level was on the way, it would be all the way up until August 11th, 2022 before we saw the actual permission level, but now we have it! So as a developer, what do I do? Honestly surprisingly little! You’ll need to grant the app the Sites.Selected permission instead of whichever permission you currently have i.e. Sites.ReadWrite.All Now your app will get an Access Denied, this is what we want! Now we need to grant our app access to the sites we want it to use, you could easily do this with PnP.PowerShell using the Grant-PnPAzureADAppSitePermission cmdlet. Grant-PnPAzureADAppSitePermission -AppId -DisplayName -Permissions [-Site ] However I find clients are reluctant to give me the Sites.FullControl.All permission required to do this. So I generally do a screenshare with the client and get them to go to the Graph Explorer And first we need the ID of the site - I use this query to get it with a minimal JSON as possible, as to help the client understand what’s going on GET | https://graph.microsoft.com/v1.0/sites?select=webUrl,Title,Id&$search="*" { "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#sites", "value": [ { "id": "contoso.sharepoint.com,361cafff-021d-4889-81e1-302ab581cf53,3c2ab6a9-dd75-48b5-82bf-ed8bb59105ba", "webUrl": "https://contoso.sharepoint.com/sites/YourSite", "displayName": "Your site" }, .... ] } Now we’ll simply copy the id for the site we wish to grant our app permission to and run the following requests on that: POST | https://graph.microsoft.com/v1.0/sites//permissions i.e. POST | https://graph.microsoft.com/v1.0/sites/contoso.sharepoint.com,361cafff-021d-4889-81e1-302ab581cf53,3c2ab6a9-dd75-48b5-82bf-ed8bb59105ba/permissions Body: { "roles": [ "write" //or "read" - depending on your needs ], "grantedToIdentities": [ { "application": { "id": "cfa6b32c-acaa-462b-b7b2-113c72c9faa7", //ClientId for your app registration "displayName": "My super cool yet limited app" //A display name the app, only used to identify it later on this site } } ] } That’s it, run your code as normally! Closing thoughts Honestly it’s hard to find any good reasons NOT to use Sites.Selected every time, but I’ve tried anyways You need Sites.FullControl.All permission to add more sites to your allowed sites, and there isn’t any easy way to see which sites an app registration has access to. It would be really nice to see a centralized way to manage this - and a way to create new sites and automatically have access to it for provisioning cases, so you don’t need a separate app with the Sites.FullControl.All just to assign permissions to your other apps. TL;DR Sites.Selected gives you all the permissions you need, but limited to a single site, requires a bit more setup, but should probably be preferred. --- ### You can now change your tenant name - but should you Forfatter: Dan Toft Dato: 2022-11-19 URL: https://pinksky.dk/blog/forfatter/dan-toft/2022-11-change-tenant/ Into Microsoft recently updated their documentation on SharePoint Tenant renmaes to now allow tenants with less than 10.000 sites to finally change their tenant name! That means you can now go from having your URL say CompanyA.SharePoint.com to CompanyB.SharePoint.com. Now before you rush off and change it right away, there are a few gotchas to watch out for! A lot of issues are time related and will fix themselves within 48-72 hours, these I won’t cover in this blog post - but beware that it might be a good idea to do this on a friday after hours, or start of a vacation. Gotchas But there are still some things that might/will break: Most of your apps need to be updated to recent versions for this to work (see the docs). Hub site menu items should be updated automatically as they’re relative URLs, but sometimes they’ll have been set as absolute URLs and therefore need to be manually updated. All hub connected sites will need to be disconnected and rejoined. The OneDrive Sync Clients folder won’t be renamed. Search Index might be slow to update. Third party app that don’t handle a HTTP 308 correctly will fail. eDiscovery hold will get stuck until you update the URL. Power Automate flows that use the request sign off feature will break and you’ll need to recreate them. Any flows deployed as solutions with managed layers that use SharePoint as a connection won’t work. Power BI connections will need to be updated. Project Online Workflow will straight up break if they’re “in flight”. Project Online references to PWA sites will need to be manually updated. Some web parts might break if they have absolute urls. Lots of teams tabs will need to be re-added. (files, embedded sites) Teams Wiki images will break - a fix is in the doc Any sites that have been deleted can’t be restored after the change. Locked sites or OneDrive accounts cannot be renamed. Those are my key takeaways from the docs the Microsoft docs, there are other edge cages, such as not being able to rename if you have a Multi-geo tenant, so if you’re really serious about a rename, go ahead and read the docs, and make sure you understand everything before you start. TL;DR PROBABLY DON’T ---