diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 6a4f107a168..ebff93d2fe3 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -22,6 +22,7 @@ const dictionary = [ 'catalog', 'checklist', 'client', + 'column', 'comm', 'command', 'community', @@ -116,6 +117,7 @@ const dictionary = [ 'unit', 'url', 'user', + 'value', 'web', 'webhook' ]; diff --git a/docs/docs/cmd/spo/list/list-defaultcolumnvalue-list.mdx b/docs/docs/cmd/spo/list/list-defaultcolumnvalue-list.mdx new file mode 100644 index 00000000000..33ebfb40670 --- /dev/null +++ b/docs/docs/cmd/spo/list/list-defaultcolumnvalue-list.mdx @@ -0,0 +1,110 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spo list defaultcolumnvalue list + +Retrieves default column values set for a specific document library + +## Usage + +```sh +m365 spo list defaultcolumnvalue list [options] +``` + +## Options + +```md definition-list +`-u, --webUrl ` +: URL of the site where the list is located. + +`-i, --listId [listId]` +: ID of the list. Specify either `listTitle`, `listId`, or `listUrl`. + +`-t, --listTitle [listTitle]` +: Title of the list. Specify either `listTitle`, `listId`, or `listUrl`. + +`--listUrl [listUrl]` +: Server- or site-relative URL of the list. Specify either `listTitle`, `listId`, or `listUrl`. + +`--folderUrl [folderUrl]` +: Only get default column values from a specific folder. +``` + + + +## Examples + +List all default column values of a list specified by title + +```sh +m365 spo list defaultcolumnvalue list --webUrl https://contoso.sharepoint.com/sites/marketing --listTitle "Project Documents" +``` + +List all default column values of a list specified by ID + +```sh +m365 spo list defaultcolumnvalue list --webUrl https://contoso.sharepoint.com/sites/marketing --listId 12345678-90ab-cdef-1234-567890abcdef +``` + +List all default column values of a list specified by server relative URL + +```sh +m365 spo list defaultcolumnvalue list --webUrl https://contoso.sharepoint.com/sites/marketing --listUrl "/sites/marketing/Project Documents" +``` + +List all default column values of a specific folder in a list specified by site relative URLs + +```sh +m365 spo list defaultcolumnvalue list --webUrl https://contoso.sharepoint.com/sites/marketing --listUrl "/Project Documents" --folderUrl "/Project Documents/Archive" +``` + +## Response + + + + + ```json + [ + { + "Field": "Countries", + "Value": "19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2", + "Path": "/sites/Marketing/Project Documents" + } + ] + ``` + + + + + ```text + Field Value Path + --------- ------------------------------------------------ ---------------------------------- + Countries 19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2 /sites/Marketing/Project Documents + ``` + + + + + ```csv + Field,Value,Path + Countries,19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2,/sites/Marketing/Project Documents + ``` + + + + + ```md + # spo list defaultcolumnvalue list --webUrl "https://contoso.sharepoint.com/sites/marketing" --listUrl "/Project Documents" + + Date: 20/10/2024 + + Property | Value + ---------|------- + Field | Countries + Value | 19;#Belgium\|442affc2-7fab-4f33-9590-330403a579c2 + Path | /sites/Marketing/Project Documents + ``` + + + diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index a9c08be2d4d..9502baf1688 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -2828,6 +2828,11 @@ const sidebars: SidebarsConfig = { label: 'list contenttype default set', id: 'cmd/spo/list/list-contenttype-default-set' }, + { + type: 'doc', + label: 'list defaultcolumnvalue list', + id: 'cmd/spo/list/list-defaultcolumnvalue-list' + }, { type: 'doc', label: 'list retentionlabel ensure', diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index 9ad1de6b0f2..8c608d4998f 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -138,6 +138,7 @@ export default { LIST_CONTENTTYPE_LIST: `${prefix} list contenttype list`, LIST_CONTENTTYPE_REMOVE: `${prefix} list contenttype remove`, LIST_CONTENTTYPE_DEFAULT_SET: `${prefix} list contenttype default set`, + LIST_DEFAULTCOLUMNVALUE_LIST: `${prefix} list defaultcolumnvalue list`, LIST_GET: `${prefix} list get`, LIST_LIST: `${prefix} list list`, LIST_REMOVE: `${prefix} list remove`, diff --git a/src/m365/spo/commands/list/list-defaultcolumnvalue-list.spec.ts b/src/m365/spo/commands/list/list-defaultcolumnvalue-list.spec.ts new file mode 100644 index 00000000000..0ecbdbbf4b2 --- /dev/null +++ b/src/m365/spo/commands/list/list-defaultcolumnvalue-list.spec.ts @@ -0,0 +1,307 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { Logger } from '../../../../cli/Logger.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './list-defaultcolumnvalue-list.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { CommandError } from '../../../../Command.js'; + +describe(commands.LIST_DEFAULTCOLUMNVALUE_LIST, () => { + const siteUrl = 'https://contoso.sharepoint.com/sites/marketing'; + const listId = 'c090e594-3b8e-4f4d-9b9f-3e8e1f0b9f1a'; + const listTitle = 'Documents'; + const listUrl = '/sites/marketing/Shared Documents'; + const siteRelListUrl = '/Shared Documents'; + + const defaultColumnXml = ` + + + 19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6 + General + + + 20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5 + Logo + + + Template + + `; + + const defaultColumnValues = [ + { + Field: 'Countries', + Value: '19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6', + Path: '/sites/Marketing/Shared Documents' + }, + { + Field: 'DocumentType', + Value: 'General', + Path: '/sites/Marketing/Shared Documents' + }, + { + Field: 'Countries', + Value: '20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5', + Path: '/sites/Marketing/Shared Documents/Logos' + }, + { + Field: 'DocumentType', + Value: 'Logo', + Path: '/sites/Marketing/Shared Documents/Logos' + }, + { + Field: 'DocumentType', + Value: 'Template', + Path: '/sites/Marketing/Shared Documents/Templates' + } + ]; + + let log: string[]; + let logger: Logger; + let loggerLogSpy: sinon.SinonSpy; + let commandInfo: CommandInfo; + let commandOptionsSchema: z.ZodTypeAny; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + + commandInfo = cli.getCommandInfo(command); + commandOptionsSchema = commandInfo.command.getSchemaToParse()!; + auth.connection.active = true; + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + loggerLogSpy = sinon.spy(logger, 'log'); + }); + + afterEach(() => { + sinonUtil.restore([ + request.get + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.LIST_DEFAULTCOLUMNVALUE_LIST); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('fails validation if webUrl is not a valid URL', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: 'invalid', listId: listId }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if listId is not a valid GUID', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: 'invalid' }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if listId, listTitle and listUrl are not specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if listId and listTitle are specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, listTitle: listTitle }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if listId and listUrl are specified', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, listUrl: listUrl }); + assert.strictEqual(actual.success, false); + }); + + it('only outputs one single result', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists('${listId}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listId: listId } }); + assert(loggerLogSpy.calledOnce); + }); + + it('correctly retrieves column default values for the specified list by listId', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists('${listId}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listId: listId, verbose: true } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], defaultColumnValues); + }); + + it('correctly retrieves column default values for the specified list by listTitle', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists/GetByTitle('${listTitle}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listTitle: listTitle, verbose: true } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], defaultColumnValues); + }); + + it('correctly retrieves column default values for the specified list by listUrl', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${listUrl}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl, verbose: true } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], defaultColumnValues); + }); + + it('correctly retrieves column default values for the specified list by listUrl when using a site relative URL', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${listUrl}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: siteRelListUrl, verbose: true } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], defaultColumnValues); + }); + + it('correctly filters column default values for the specified folder', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${listUrl}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: siteRelListUrl, folderUrl: '/sites/Marketing/Shared Documents/Logos' } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], defaultColumnValues.filter(d => d.Path.toLowerCase() === '/sites/marketing/shared documents/logos')); + }); + + it('correctly filters column default values for the specified folder with a site relative URL', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${listUrl}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: siteRelListUrl, folderUrl: '/Shared Documents/TeMpLaTeS' } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], defaultColumnValues.filter(d => d.Path.toLowerCase() === '/sites/marketing/shared documents/templates')); + }); + + it('correctly retrieves column default values for list without any default values set', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${listUrl}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + throw { status: 404, error: { 'odata.error': { message: { value: 'The file does not exist.' } } } }; + } + + throw 'Invalid request: ' + opts.url; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl } }); + assert.deepStrictEqual(loggerLogSpy.firstCall.args[0], []); + }); + + it('correctly handles error when list is not found', async () => { + sinon.stub(request, 'get').rejects({ status: 404 }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl } }), + new CommandError(`List '${listUrl}' was not found.`)); + }); + + it('correctly handles error when retrieving column list', async () => { + sinon.stub(request, 'get').rejects({ error: { 'odata.error': { message: { value: 'An error has occurred' } } } }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listId: listId } }), + new CommandError('An error has occurred')); + }); + + it('correctly handles error when retrieving column default values', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${listUrl}')/RootFolder?$select=ServerRelativeUrl`) { + return { ServerRelativeUrl: listUrl }; + } + + throw { error: { 'odata.error': { message: { value: 'An error has occurred' } } } }; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl } }), + new CommandError('An error has occurred')); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/list/list-defaultcolumnvalue-list.ts b/src/m365/spo/commands/list/list-defaultcolumnvalue-list.ts new file mode 100644 index 00000000000..8d71312dc0c --- /dev/null +++ b/src/m365/spo/commands/list/list-defaultcolumnvalue-list.ts @@ -0,0 +1,175 @@ +import SpoCommand from '../../../base/SpoCommand.js'; +import { globalOptionsZod } from '../../../../Command.js'; +import { z } from 'zod'; +import { zod } from '../../../../utils/zod.js'; +import { Logger } from '../../../../cli/Logger.js'; +import commands from '../../commands.js'; +import { DOMParser } from '@xmldom/xmldom'; +import { validation } from '../../../../utils/validation.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; + +interface DefaultColumnValue { + Field: string; + Value: string; + Path: string +} + +const options = globalOptionsZod + .extend({ + webUrl: zod.alias('u', z.string() + .refine(url => validation.isValidSharePointUrl(url) === true, url => ({ + message: `'${url}' is not a valid SharePoint Online site URL.` + })) + ), + listId: zod.alias('i', z.string().optional() + .refine(id => id === undefined || validation.isValidGuid(id), id => ({ + message: `'${id}' is not a valid GUID.` + })) + ), + listTitle: zod.alias('t', z.string().optional()), + listUrl: z.string().optional(), + folderUrl: z.string().optional() + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpoListDefaultColumnValueListCommand extends SpoCommand { + public get name(): string { + return commands.LIST_DEFAULTCOLUMNVALUE_LIST; + } + + public get description(): string { + return 'Retrieves default column values set for a specific document library'; + } + + public get schema(): z.ZodTypeAny { + return options; + } + + public getRefinedSchema(schema: z.ZodTypeAny): z.ZodEffects | undefined { + return schema + .refine(options => [options.listId, options.listTitle, options.listUrl].filter(o => o !== undefined).length === 1, { + message: 'Use one of the following options: listId, listTitle, listUrl.' + }); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (this.verbose) { + await logger.logToStderr(`Retrieving default column values for list '${args.options.listId || args.options.listTitle || args.options.listUrl}'...`); + } + + if (this.verbose) { + await logger.logToStderr('Retrieving list information...'); + } + const listServerRelUrl = await this.getServerRelativeListUrl(args.options); + + if (this.verbose) { + await logger.logToStderr('Retrieving default column values...'); + } + + let defaultValues: DefaultColumnValue[]; + try { + const defaultValuesXml = await this.getDefaultColumnValuesXml(args.options.webUrl, listServerRelUrl); + defaultValues = this.convertXmlToJson(defaultValuesXml); + } + catch (err: any) { + if (err.status !== 404) { + throw err; + } + // For lists that have never had default column values set, the client_LocationBasedDefaults.html file does not exist. + defaultValues = []; + } + + if (args.options.folderUrl) { + const serverRelFolderUrl = urlUtil.removeTrailingSlashes(urlUtil.getServerRelativePath(args.options.webUrl, args.options.folderUrl)); + defaultValues = defaultValues.filter(d => d.Path.toLowerCase() === serverRelFolderUrl.toLowerCase()); + } + await logger.log(defaultValues); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private async getServerRelativeListUrl(options: Options): Promise { + const requestOptions: CliRequestOptions = { + url: `${options.webUrl}/_api/Web`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + if (options.listUrl) { + const serverRelativeUrl = urlUtil.getServerRelativePath(options.webUrl, options.listUrl); + requestOptions.url += `/GetList('${serverRelativeUrl}')`; + } + else if (options.listId) { + requestOptions.url += `/Lists('${options.listId}')`; + } + else if (options.listTitle) { + requestOptions.url += `/Lists/GetByTitle('${formatting.encodeQueryParameter(options.listTitle)}')`; + } + + requestOptions.url += '/RootFolder?$select=ServerRelativeUrl'; + + try { + const response = await request.get<{ ServerRelativeUrl: string }>(requestOptions); + return response.ServerRelativeUrl; + } + catch (error: any) { + if (error.status === 404) { + throw `List '${options.listId || options.listTitle || options.listUrl}' was not found.`; + } + + throw error; + } + } + + private async getDefaultColumnValuesXml(webUrl: string, listServerRelUrl: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${webUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listServerRelUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + const defaultValuesXml = await request.get(requestOptions); + return defaultValuesXml; + } + + private convertXmlToJson(xml: string): DefaultColumnValue[] { + const results: DefaultColumnValue[] = []; + + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'application/xml'); + + const folderLinks = doc.getElementsByTagName('a'); + for (let i = 0; i < folderLinks.length; i++) { + const folderUrl = folderLinks[i].getAttribute('href')!; + const defaultValues = folderLinks[i].getElementsByTagName('DefaultValue'); + + for (let j = 0; j < defaultValues.length; j++) { + const fieldName = defaultValues[j].getAttribute('FieldName')!; + const fieldValue = defaultValues[j].textContent!; + + results.push({ + Field: fieldName, + Value: fieldValue, + Path: folderUrl + }); + } + } + + return results; + } +} + +export default new SpoListDefaultColumnValueListCommand(); \ No newline at end of file