diff --git a/docs/docs/cmd/spo/list/list-defaultvalue-set.mdx b/docs/docs/cmd/spo/list/list-defaultvalue-set.mdx new file mode 100644 index 0000000000..52c0b4e865 --- /dev/null +++ b/docs/docs/cmd/spo/list/list-defaultvalue-set.mdx @@ -0,0 +1,112 @@ +import Global from '/docs/cmd/_global.mdx'; + +# spo list defaultvalue set + +Sets default column values for a specific document library + +## Usage + +```sh +m365 spo list defaultvalue set [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`. + +`--fieldName ` +: Internal name of the field. + +`--fieldValue ` +: Default value of the field. + +`--folderUrl [folderUrl]` +: Set the value to a specific folder. By default, the root folder of the list is used. +``` + + + +# Remarks + +:::note + +Due to limitations in SharePoint Online, setting default column values for folders with a `#` or `%` character in their path is not supported. + +::: + +## Examples + +Set a default folder value on the root folder of a list for a text field + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listTitle Logos --fieldName Company --fieldValue Contoso +``` + +Set a default folder value for a taxonomy field on a specific folder + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listTitle Logos --folderUrl "/sites/Marketing/Logos/Contoso" --fieldName Country --fieldValue "-1;#Belgium|442affc2-7fab-4f33-9590-330403a579c2" +``` + +Set a default folder value for a multi-taxonomy field on a specific folder + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listUrl /sites/marketing/Logos --folderUrl "/sites/Marketing/Logos/Contoso" --fieldName Countries --fieldValue "-1;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#-1;#France|14888324-5c48-46db-b748-215cbe24eb4c" +``` + +Set a default folder value for a date field to today + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listId 3540460d-cb6e-4bc5-8380-bc1844401ee3 --fieldName Published --fieldValue "[today]" +``` + +Set a default folder value for a date field + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listTitle Logos --fieldName Published --fieldValue "2020-05-03T11:00:00Z" +``` + +Set a default folder value for a choice field + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listUrl /sites/marketing/Logos --fieldName FileType --fieldValue Logo +``` + +Set a default folder value for a multi-choice field + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listTitle Logos --fieldName FileTypes --fieldValue "Logo;#Brand" +``` + +Set a default folder value for a yes/no field using site-relative list URL + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listUrl /Logos --fieldName Active --fieldValue 1 +``` + +Set a default folder value for a user field + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listId 3540460d-cb6e-4bc5-8380-bc1844401ee3 --fieldName Responsible --fieldValue "1;#john.doe@contoso.com" +``` + +Set a default folder value for a multi-user field + +```sh +m365 spo list defaultvalue set --webUrl https://contoso.sharepoint.com/sites/Marketing --listId 3540460d-cb6e-4bc5-8380-bc1844401ee3 --fieldName Responsible --fieldValue "1;#john.doe@contoso.com;#2;#adele.vance@contoso.com" +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 1d77562278..99205b146d 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -2903,6 +2903,11 @@ const sidebars: SidebarsConfig = { label: 'list defaultvalue list', id: 'cmd/spo/list/list-defaultvalue-list' }, + { + type: 'doc', + label: 'list defaultvalue set', + id: 'cmd/spo/list/list-defaultvalue-set' + }, { type: 'doc', label: 'list retentionlabel ensure', diff --git a/src/m365/spo/commands.ts b/src/m365/spo/commands.ts index 128ba10abf..68bf6c3060 100644 --- a/src/m365/spo/commands.ts +++ b/src/m365/spo/commands.ts @@ -139,6 +139,7 @@ export default { LIST_CONTENTTYPE_REMOVE: `${prefix} list contenttype remove`, LIST_CONTENTTYPE_DEFAULT_SET: `${prefix} list contenttype default set`, LIST_DEFAULTVALUE_LIST: `${prefix} list defaultvalue list`, + LIST_DEFAULTVALUE_SET: `${prefix} list defaultvalue set`, LIST_GET: `${prefix} list get`, LIST_LIST: `${prefix} list list`, LIST_REMOVE: `${prefix} list remove`, diff --git a/src/m365/spo/commands/list/list-defaultvalue-set.spec.ts b/src/m365/spo/commands/list/list-defaultvalue-set.spec.ts new file mode 100644 index 0000000000..895cdaacdf --- /dev/null +++ b/src/m365/spo/commands/list/list-defaultvalue-set.spec.ts @@ -0,0 +1,405 @@ +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-defaultvalue-set.js'; +import { z } from 'zod'; +import { cli } from '../../../../cli/cli.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { CommandError } from '../../../../Command.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; + +describe(commands.LIST_DEFAULTVALUE_SET, () => { + 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 folderUrl = '/sites/Marketing/Shared Documents/Logos'; + const fieldName = 'DocumentType'; + const fieldValue = 'Logo'; + + const defaultColumnXml = `19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6General20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5Template`; + + 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, + request.post, + request.put + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.LIST_DEFAULTVALUE_SET); + }); + + 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, fieldName: fieldName, fieldValue: fieldValue }); + 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, fieldName: fieldName, fieldValue: fieldValue }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if fieldValue is an empty string', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, fieldValue: '', fieldName: fieldName }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if folderUrl contains a # character', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, folderUrl: '/sites/marketing/Shared Documents/Logos#/Contoso', fieldName: fieldName, fieldValue: fieldValue }); + assert.strictEqual(actual.success, false); + }); + + it('fails validation if folderUrl contains a % character', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, folderUrl: '/sites/marketing/Shared Documents/Logos%/Contoso', fieldName: fieldName, fieldValue: fieldValue }); + assert.strictEqual(actual.success, false); + }); + + it('succeeds validation with folderUrl parameter', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, folderUrl: folderUrl, fieldName: fieldName, fieldValue: fieldValue }); + assert.strictEqual(actual.success, true); + }); + + it('succeeds validation without folderUrl parameter', async () => { + const actual = commandOptionsSchema.safeParse({ webUrl: siteUrl, listId: listId, fieldName: fieldName, fieldValue: fieldValue }); + assert.strictEqual(actual.success, true); + }); + + it('sets default column value for a field without generating output', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${formatting.encodeQueryParameter(listUrl)}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + 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}`; + }); + + sinon.stub(request, 'put').resolves(); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl, fieldValue: fieldValue, fieldName: fieldName, verbose: true } }); + assert(loggerLogSpy.notCalled); + }); + + it('updates an existing default column value', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists/GetByTitle('${formatting.encodeQueryParameter(listTitle)}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + 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}`; + }); + + const putStub = sinon.stub(request, 'put').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return; + } + + throw `Invalid request: ${opts.url}`; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listTitle: listTitle, fieldValue: fieldValue, fieldName: fieldName } }); + assert.deepStrictEqual(putStub.firstCall.args[0].data, `19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6Logo20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5Template`); + }); + + it('adds a default column value to a folder that already has default values', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists('${listId}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + ServerRelativeUrl: listUrl + } + }; + } + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + if (opts.url === `${siteUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(folderUrl)}')/ListItemAllFields?$select=FileRef`) { + return { + FileRef: folderUrl + }; + } + + throw `Invalid GET request: ${opts.url}`; + }); + + const putStub = sinon.stub(request, 'put').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return; + } + + throw `Invalid PUT request: ${opts.url}`; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listId: listId, fieldValue: fieldValue, fieldName: fieldName, folderUrl: folderUrl, verbose: true } }); + assert.deepStrictEqual(putStub.firstCall.args[0].data, `19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6General20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5LogoTemplate`); + }); + + it('adds a default column value to a list that has no default folder values', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${formatting.encodeQueryParameter(listUrl)}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + 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.' } } } }; + } + if (opts.url === `${siteUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(folderUrl)}')/ListItemAllFields?$select=FileRef`) { + return { + FileRef: folderUrl + }; + } + + throw `Invalid GET request: ${opts.url}`; + }); + + const putStub = sinon.stub(request, 'put').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return; + } + + throw `Invalid PUT request: ${opts.url}`; + }); + + const postStub = sinon.stub(request, 'post').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms')}')/Files/Add(url='client_LocationBasedDefaults.html', overwrite=false)`) { + return; + } + + throw `Invalid POST request: ${opts.url}`; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl, fieldValue: fieldValue, fieldName: fieldName, folderUrl: folderUrl } }); + assert.deepStrictEqual(postStub.firstCall.args[0].data, ''); + assert.deepStrictEqual(putStub.firstCall.args[0].data, `Logo`); + }); + + it('adds a default column value correctly to a folder with an incorrect cased path', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists('${listId}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + ServerRelativeUrl: listUrl + } + }; + } + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + if (opts.url === `${siteUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(urlUtil.getServerRelativePath(siteUrl, folderUrl.toUpperCase()))}')/ListItemAllFields?$select=FileRef`) { + return { + FileRef: folderUrl + }; + } + + throw `Invalid GET request: ${opts.url}`; + }); + + const putStub = sinon.stub(request, 'put').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return; + } + + throw `Invalid PUT request: ${opts.url}`; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listId: listId, fieldValue: fieldValue, fieldName: fieldName, folderUrl: folderUrl.toUpperCase(), verbose: true } }); + assert.deepStrictEqual(putStub.firstCall.args[0].data, `19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6General20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5LogoTemplate`); + }); + + it('adds a default column value correctly when site relative url is used for folder', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists('${listId}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + ServerRelativeUrl: listUrl + } + }; + } + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return defaultColumnXml; + } + if (opts.url === `${siteUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(folderUrl)}')/ListItemAllFields?$select=FileRef`) { + return { + FileRef: folderUrl + }; + } + + throw `Invalid GET request: ${opts.url}`; + }); + + const putStub = sinon.stub(request, 'put').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + return; + } + + throw `Invalid PUT request: ${opts.url}`; + }); + + await command.action(logger, { options: { webUrl: siteUrl, listId: listId, fieldValue: fieldValue, fieldName: fieldName, folderUrl: '/Shared Documents/Logos' } }); + assert.deepStrictEqual(putStub.firstCall.args[0].data, `19;#Belgium|442affc2-7fab-4f33-9590-330403a579c2;#18;#Croatia|59f1ab85-235b-4cf8-b669-4373cc9393c6General20;#Canada|e3d25461-68ef-4070-8523-5ba439f6d4d5LogoTemplate`); + }); + + it('throws error when running the command on a non-document library', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/Lists('${listId}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 100, + RootFolder: { + ServerRelativeUrl: listUrl + } + }; + } + + throw `Invalid request: ${opts.url}`; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listId: listId, fieldName: fieldName, fieldValue: fieldValue } }), + new CommandError('The specified list is not a document library.')); + }); + + it('throws error when list does not exist', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${formatting.encodeQueryParameter(listUrl)}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + throw { status: 404, error: { 'odata.error': { message: { value: 'The file does not exist.' } } } }; + } + + throw `Invalid request: ${opts.url}`; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl, fieldName: fieldName, fieldValue: fieldValue } }), + new CommandError(`List '${listUrl}' was not found.`)); + }); + + it('throws error when folder does not exist', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${formatting.encodeQueryParameter(listUrl)}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + ServerRelativeUrl: listUrl + } + }; + } + if (opts.url === `${siteUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(folderUrl)}')/ListItemAllFields?$select=FileRef`) { + return { + FileRef: null + }; + } + + throw `Invalid request: ${opts.url}`; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl, fieldName: fieldName, fieldValue: fieldValue, folderUrl: folderUrl } }), + new CommandError(`Folder '${folderUrl}' was not found.`)); + }); + + it('throws error when error occurs when retrieving default column values file', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `${siteUrl}/_api/Web/GetList('${formatting.encodeQueryParameter(listUrl)}')?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate`) { + return { + BaseTemplate: 101, + RootFolder: { + ServerRelativeUrl: listUrl + } + }; + } + if (opts.url === `${siteUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`) { + throw { status: 401, error: { 'odata.error': { message: { value: 'You don\'t have permission to view this file.' } } } }; + } + + throw `Invalid request: ${opts.url}`; + }); + + await assert.rejects(command.action(logger, { options: { webUrl: siteUrl, listUrl: listUrl, fieldName: fieldName, fieldValue: fieldValue } }), + new CommandError('You don\'t have permission to view this file.')); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/list/list-defaultvalue-set.ts b/src/m365/spo/commands/list/list-defaultvalue-set.ts new file mode 100644 index 0000000000..7260a45631 --- /dev/null +++ b/src/m365/spo/commands/list/list-defaultvalue-set.ts @@ -0,0 +1,256 @@ +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'; + +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(), + fieldName: z.string(), + fieldValue: z.string() + .refine(value => value !== '', `The value cannot be empty. Use 'spo list defaultvalue remove' to remove a default column value.`), + folderUrl: z.string().optional() + .refine(url => url === undefined || (!url.includes('#') && !url.includes('%')), 'Due to limitations in SharePoint Online, setting default column values for folders with a # or % character in their path is not supported.') + }) + .strict(); +declare type Options = z.infer; + +interface CommandArgs { + options: Options; +} + +class SpoListDefaultValueSetCommand extends SpoCommand { + public get name(): string { + return commands.LIST_DEFAULTVALUE_SET; + } + + public get description(): string { + return 'Sets default column values 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(`Setting default column value '${args.options.fieldValue}' for field '${args.options.fieldName}'...`); + await logger.logToStderr(`Getting server-relative URL of the list...`); + } + + const listServerRelUrl = await this.getServerRelativeListUrl(args.options); + + let folderUrl = listServerRelUrl; + if (args.options.folderUrl) { + if (this.verbose) { + await logger.logToStderr(`Getting server-relative URL of folder '${args.options.folderUrl}'...`); + } + + // Casing of the folder URL is important, let's retrieve the correct URL + const serverRelativeFolderUrl = urlUtil.getServerRelativePath(args.options.webUrl, urlUtil.removeTrailingSlashes(args.options.folderUrl)); + folderUrl = await this.getCorrectFolderUrl(args.options.webUrl, serverRelativeFolderUrl); + } + + if (this.verbose) { + await logger.logToStderr(`Getting default column values...`); + } + + const defaultValuesXml = await this.ensureDefaultColumnValuesXml(args.options.webUrl, listServerRelUrl); + const modifiedXml = await this.updateFieldValueXml(logger, defaultValuesXml, args.options.fieldName, args.options.fieldValue, folderUrl); + await this.uploadDefaultColumnValuesXml(logger, args.options.webUrl, listServerRelUrl, modifiedXml); + } + 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('${formatting.encodeQueryParameter(serverRelativeUrl)}')`; + } + else if (options.listId) { + requestOptions.url += `/Lists('${options.listId}')`; + } + else if (options.listTitle) { + requestOptions.url += `/Lists/GetByTitle('${formatting.encodeQueryParameter(options.listTitle)}')`; + } + + requestOptions.url += '?$expand=RootFolder&$select=RootFolder/ServerRelativeUrl,BaseTemplate'; + + try { + const response = await request.get<{ BaseTemplate: number; RootFolder: { ServerRelativeUrl: string } }>(requestOptions); + if (response.BaseTemplate !== 101) { + throw `The specified list is not a document library.`; + } + + return response.RootFolder.ServerRelativeUrl; + } + catch (error: any) { + if (error.status === 404) { + throw `List '${options.listId || options.listTitle || options.listUrl}' was not found.`; + } + + throw error; + } + } + + private async getCorrectFolderUrl(webUrl: string, folderUrl: string): Promise { + const requestOptions: CliRequestOptions = { + // Using ListItemAllFields endpoint because GetFolderByServerRelativePath doesn't return the correctly cased URL + url: `${webUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(folderUrl)}')/ListItemAllFields?$select=FileRef`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const response = await request.get<{ FileRef: string }>(requestOptions); + if (!response.FileRef) { + throw `Folder '${folderUrl}' was not found.`; + } + + return response.FileRef; + } + + private async ensureDefaultColumnValuesXml(webUrl: string, listServerRelUrl: string): Promise { + try { + 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; + } + 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. + // In this case, we need to create the file with blank default metadata. + const requestOptions: CliRequestOptions = { + url: `${webUrl}/_api/Web/GetFolderByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listServerRelUrl + '/Forms')}')/Files/Add(url='client_LocationBasedDefaults.html', overwrite=false)`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'text/plain' + }, + responseType: 'json', + data: '' + }; + + await request.post(requestOptions); + return requestOptions.data; + } + } + + private async updateFieldValueXml(logger: Logger, xml: string, fieldName: string, fieldValue: string, folderUrl: string): Promise { + if (this.verbose) { + await logger.logToStderr(`Modifying default column values...`); + } + // Encode all spaces in the folder URL + const encodedFolderUrl = folderUrl.replace(/ /g, '%20'); + + const parser = new DOMParser(); + const doc = parser.parseFromString(xml, 'application/xml'); + + // Create a new DefaultValue node + const newDefaultValueNode = doc.createElement('DefaultValue'); + newDefaultValueNode.setAttribute('FieldName', fieldName); + newDefaultValueNode.textContent = fieldValue; + + const folderLinks = doc.getElementsByTagName('a'); + for (let i = 0; i < folderLinks.length; i++) { + const folderNode = folderLinks[i]; + const folderNodeUrl = folderNode.getAttribute('href')!; + + if (encodedFolderUrl !== folderNodeUrl) { + continue; + } + + const defaultValues = folderNode.getElementsByTagName('DefaultValue'); + for (let j = 0; j < defaultValues.length; j++) { + const defaultValueNode = defaultValues[j]; + const defaultValueNodeField = defaultValueNode.getAttribute('FieldName')!; + + if (defaultValueNodeField !== fieldName) { + continue; + } + + // Default value node found, let's update the value + defaultValueNode.textContent = fieldValue; + return doc.toString(); + } + + // Default value node not found, let's create it + folderNode.appendChild(newDefaultValueNode); + return doc.toString(); + } + + // Folder node was not found, let's create it + const newFolderNode = doc.createElement('a'); + newFolderNode.setAttribute('href', encodedFolderUrl); + newFolderNode.appendChild(newDefaultValueNode); + doc.documentElement!.appendChild(newFolderNode); + + return doc.toString(); + } + + private async uploadDefaultColumnValuesXml(logger: Logger, webUrl: string, listServerRelUrl: string, xml: string): Promise { + if (this.verbose) { + await logger.logToStderr(`Uploading default column values to list...`); + } + + const requestOptions: CliRequestOptions = { + url: `${webUrl}/_api/Web/GetFileByServerRelativePath(decodedUrl='${formatting.encodeQueryParameter(listServerRelUrl + '/Forms/client_LocationBasedDefaults.html')}')/$value`, + headers: { + accept: 'application/json;odata=nometadata', + 'content-type': 'text/plain' + }, + responseType: 'json', + data: xml + }; + + await request.put(requestOptions); + } +} + +export default new SpoListDefaultValueSetCommand(); \ No newline at end of file