From c288acd4928dfac2c006caca320c2efa67d80bb0 Mon Sep 17 00:00:00 2001 From: XingY Date: Tue, 24 Feb 2026 18:52:00 -0800 Subject: [PATCH 1/4] GitHub Issue 867: Cannot filter through a file data field --- .../study/assay/FileLinkDisplayColumn.java | 954 ++--- api/webapp/clientapi/ext3/FilterDialog.js | 3505 +++++++++-------- 2 files changed, 2231 insertions(+), 2228 deletions(-) diff --git a/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java b/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java index 970fa700288..dcc11fd615e 100644 --- a/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java +++ b/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java @@ -1,477 +1,477 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.study.assay; - -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.admin.CoreUrls; -import org.labkey.api.attachments.Attachment; -import org.labkey.api.data.AbstractFileDisplayColumn; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.RemappingDisplayColumnFactory; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.TableViewForm; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.files.FileContentService; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.util.ContainerContext; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Path; -import org.labkey.api.util.URIUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.webdav.WebdavResource; -import org.labkey.api.webdav.WebdavService; -import org.labkey.api.writer.HtmlWriter; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class FileLinkDisplayColumn extends AbstractFileDisplayColumn -{ - // Issue 46282 - let admins choose if files should be rendered inside browser or downloaded as files - public static final String AS_ATTACHMENT_FORMAT = "attachment"; - public static final String AS_INLINE_FORMAT = "inline"; - - public static class Factory implements RemappingDisplayColumnFactory - { - private final Container _container; - - private PropertyDescriptor _pd; - private DetailsURL _detailsUrl; - private SchemaKey _schemaKey; - private String _queryName; - private FieldKey _pkFieldKey; - private FieldKey _objectURIFieldKey; - - public Factory(PropertyDescriptor pd, Container c, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) - { - _pd = pd; - _container = c; - _schemaKey = schemaKey; - _queryName = queryName; - _pkFieldKey = pkFieldKey; - } - - public Factory(PropertyDescriptor pd, Container c, @NotNull FieldKey lsidColumnFieldKey) - { - _pd = pd; - _container = c; - _objectURIFieldKey = lsidColumnFieldKey; - } - - public Factory(DetailsURL detailsURL, Container c, @NotNull FieldKey pkFieldKey) - { - _detailsUrl = detailsURL; - _container = c; - _pkFieldKey = pkFieldKey; - } - - @Override - public Factory remapFieldKeys(@Nullable FieldKey parent, @Nullable Map remap) - { - Factory remapped = this.clone(); - if (remapped._pkFieldKey != null) - { - remapped._pkFieldKey = FieldKey.remap(_pkFieldKey, parent, remap); - if (null == remapped._pkFieldKey) - remapped._pkFieldKey = _pkFieldKey; - } - if (remapped._objectURIFieldKey != null) - { - remapped._objectURIFieldKey = FieldKey.remap(_objectURIFieldKey, parent, remap); - if (null == remapped._objectURIFieldKey) - remapped._objectURIFieldKey = _objectURIFieldKey; - } - return remapped; - } - - @Override - public DisplayColumn createRenderer(ColumnInfo col) - { - if (_pd == null && _detailsUrl != null) - return new FileLinkDisplayColumn(col, _detailsUrl, _container, _pkFieldKey); - else if (_pkFieldKey != null) - return new FileLinkDisplayColumn(col, _pd, _container, _schemaKey, _queryName, _pkFieldKey); - else if (_container != null) - return new FileLinkDisplayColumn(col, _pd, _container, _objectURIFieldKey); - else - throw new IllegalArgumentException("Cannot create a renderer from the specified configuration properties"); - } - - @Override - public FileLinkDisplayColumn.Factory clone() - { - try - { - return (Factory)super.clone(); - } - catch (CloneNotSupportedException e) - { - throw new RuntimeException(e); - } - } - } - - private final Container _container; - - private FieldKey _pkFieldKey; - private FieldKey _objectURIFieldKey; - - /** Use schemaName/queryName and pk FieldKey value to resolve File in CoreController.DownloadFileLinkAction. */ - public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) - { - super(col); - _container = container; - _pkFieldKey = pkFieldKey; - - if (pd.getURL() == null) - { - // Don't stomp over an explicitly configured URL on this column - StringBuilder sb = new StringBuilder("/core/downloadFileLink.view?propertyId="); - sb.append(pd.getPropertyId()); - sb.append("&schemaName="); - sb.append(PageFlowUtil.encodeURIComponent(schemaKey.toString())); - sb.append("&queryName="); - sb.append(PageFlowUtil.encodeURIComponent(queryName)); - sb.append("&pk=${"); - sb.append(pkFieldKey); - sb.append("}"); - sb.append("&modified=${Modified}"); - if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) - { - sb.append("&inline=false"); - } - ContainerContext context = new ContainerContext.FieldKeyContext(new FieldKey(pkFieldKey.getParent(), "Folder")); - setURLExpression(DetailsURL.fromString(sb.toString(), context)); - } - } - - /** Use LSID FieldKey value as ObjectURI to resolve File in CoreController.DownloadFileLinkAction. */ - public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull FieldKey objectURIFieldKey) - { - super(col); - _container = container; - _objectURIFieldKey = objectURIFieldKey; - - if (pd.getURL() == null) - { - // Don't stomp over an explicitly configured URL on this column - - ActionURL baseUrl = PageFlowUtil.urlProvider(CoreUrls.class).getDownloadFileLinkBaseURL(container, pd); - if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) - { - baseUrl.addParameter("inline", "false"); - } - else - { - setLinkTarget("_blank"); - } - DetailsURL url = new DetailsURL(baseUrl, "objectURI", objectURIFieldKey); - setURLExpression(url); - } - } - - public FileLinkDisplayColumn(ColumnInfo col, DetailsURL detailsURL, Container container, @NotNull FieldKey pkFieldKey) - { - super(col); - _container = container; - _pkFieldKey = pkFieldKey; - - setURLExpression(detailsURL); - } - - @Override - protected Object getInputValue(RenderContext ctx) - { - ColumnInfo col = getColumnInfo(); - Object val = null; - TableViewForm viewForm = ctx.getForm(); - - if (col != null) - { - if (null != viewForm && viewForm.contains(this, ctx)) - { - val = viewForm.getAsString(getFormFieldName(ctx)); - } - else if (ctx.getRow() != null) - val = col.getValue(ctx); - } - - return val; - } - - @Override - public void addQueryFieldKeys(Set keys) - { - super.addQueryFieldKeys(keys); - keys.add(FieldKey.fromParts("Modified")); - if (_pkFieldKey != null) - keys.add(_pkFieldKey); - if (_objectURIFieldKey != null) - keys.add(_objectURIFieldKey); - } - - public static boolean filePathExist(String path, Container container, User user) - { - String davPath = path; - if (FileUtil.isUrlEncoded(davPath)) - davPath = FileUtil.decodeURL(davPath); - var resolver = WebdavService.get().getResolver(); - // Resolve path under webdav root - Path parsed = Path.parse(StringUtils.trim(davPath)); - - // Issue 52968: handle context path - Path contextPath = AppProps.getInstance().getParsedContextPath(); - if (parsed.startsWith(contextPath)) - parsed = parsed.subpath(contextPath.size(), parsed.size()); - - WebdavResource resource = resolver.lookup(parsed); - if ((null == resource || !resource.exists()) && !parsed.startsWith(new Path("_webdav"))) - resource = resolver.lookup(new Path("_webdav").append(parsed)); - if (resource != null && resource.isFile() && resource.canRead(user, true)) - { - return true; - } - else - { - // Resolve file under pipeline root - PipeRoot root = PipelineService.get().findPipelineRoot(container); - if (root != null) - { - // Attempt absolute path first, then relative path from pipeline root - File f = new File(path); - if (!root.isUnderRoot(f)) - f = root.resolvePath(path); - - return (NetworkDrive.exists(f) && root.isUnderRoot(f) && root.hasPermission(container, user, ReadPermission.class)); - } - } - - return false; - } - - @Override - protected String getFileName(RenderContext ctx, Object value) - { - return getFileName(ctx, value, false); - } - - @Override - protected String getFileName(RenderContext ctx, Object value, boolean isDisplay) - { - String result = value == null ? null : StringUtils.trimToNull(value.toString()); - if (result != null) - { - File f = null; - if (result.startsWith("file:")) - { - try - { - f = new File(new URI(result)); - } - catch (URISyntaxException x) - { - // try to recover - result = result.substring("file:".length()); - } - } - if (null == f) - f = FileUtil.getAbsoluteCaseSensitiveFile(new File(result)); - NetworkDrive.ensureDrive(f.getPath()); - List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); - boolean valid = false; - List containers = new ArrayList<>(); - containers.add(_container); - // Not ideal, but needed in case data is queried from cross folder context - if (ctx.get("folder") != null || ctx.get("container") != null) - { - Object folderObj = ctx.get("folder"); - if (folderObj == null) - folderObj = ctx.get("container"); - if (folderObj instanceof String containerId) - { - Container dataContainer = ContainerManager.getForId(containerId); - if (dataContainer != null && !dataContainer.equals(_container)) - containers.add(dataContainer); - } - } - for (Container container : containers) - { - if (valid) - break; - - for (FileContentService.ContentType fileRootType : fileRootTypes) - { - result = relativize(f, FileContentService.get().getFileRoot(container, fileRootType)); - if (result != null) - { - // Issue 54062: Strip folder name from displayed name - if (isDisplay) - result = f.getName(); - - valid = true; - break; - } - } - } - if (result == null) - { - result = f.getName(); - } - - if ((!valid || !f.exists()) && !result.endsWith(UNAVAILABLE_FILE_SUFFIX)) - result += UNAVAILABLE_FILE_SUFFIX; - } - return result; - } - - public static String relativize(File f, File fileRoot) - { - if (fileRoot != null) - { - NetworkDrive.ensureDrive(fileRoot.getPath()); - fileRoot = FileUtil.getAbsoluteCaseSensitiveFile(fileRoot); - if (URIUtil.isDescendant(fileRoot.toURI(), f.toURI())) - { - try - { - return FileUtil.relativize(fileRoot, f, false); - } - catch (IOException ignored) {} - } - } - return null; - } - - @Override - protected InputStream getFileContents(RenderContext ctx, Object ignore) throws FileNotFoundException - { - Object value = getValue(ctx); - String s = value == null ? null : StringUtils.trimToNull(value.toString()); - if (s != null) - { - File f = new File(s); - if (f.isFile()) - return new FileInputStream(f); - } - return null; - } - - @Override - protected void renderIconAndFilename( - RenderContext ctx, - HtmlWriter out, - String fileValue /*Could be raw path value, or processed filename by `getFileName`*/, - boolean link, - boolean thumbnail) - { - Object value = getValue(ctx); - String strValue = value == null ? null : StringUtils.trimToNull(value.toString()); - if (strValue != null && !fileValue.endsWith(UNAVAILABLE_FILE_SUFFIX)) - { - File f; - if (strValue.startsWith("file:")) - f = new File(URI.create(strValue)); - else - f = new File(strValue); - - if (!f.exists()) - { - // try all file root - List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); - for (FileContentService.ContentType fileRootType : fileRootTypes) - { - String fullPath = FileContentService.get().getFileRoot(_container, fileRootType).getAbsolutePath()+ File.separator + value; - f = new File(fullPath); - if (f.exists()) - break; - } - } - - // It's probably a file, so check that first - if (f.isFile()) - { - super.renderIconAndFilename(ctx, out, strValue, link, thumbnail); - } - else if (f.isDirectory()) - { - super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(".folder"), null, link, false); - } - else - { - // It's not on the file system anymore, so don't offer a link and tell the user it's unavailable - super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(fileValue), null, false, false); - } - } - else - { - super.renderIconAndFilename(ctx, out, fileValue, link, thumbnail); - } - } - - @Override - public Object getDisplayValue(RenderContext ctx) - { - return getFileName(ctx, super.getDisplayValue(ctx), true); - } - - @Override - public Object getJsonValue(RenderContext ctx) - { - return getFileName(ctx, super.getDisplayValue(ctx)); - } - - @Override - public Object getExportCompatibleValue(RenderContext ctx) - { - return getJsonValue(ctx); - } - - @Override - public boolean isFilterable() - { - return false; - } - @Override - public boolean isSortable() - { - return false; - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.study.assay; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.admin.CoreUrls; +import org.labkey.api.attachments.Attachment; +import org.labkey.api.data.AbstractFileDisplayColumn; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.RemappingDisplayColumnFactory; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.TableViewForm; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.files.FileContentService; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.ContainerContext; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Path; +import org.labkey.api.util.URIUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.webdav.WebdavResource; +import org.labkey.api.webdav.WebdavService; +import org.labkey.api.writer.HtmlWriter; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class FileLinkDisplayColumn extends AbstractFileDisplayColumn +{ + // Issue 46282 - let admins choose if files should be rendered inside browser or downloaded as files + public static final String AS_ATTACHMENT_FORMAT = "attachment"; + public static final String AS_INLINE_FORMAT = "inline"; + + public static class Factory implements RemappingDisplayColumnFactory + { + private final Container _container; + + private PropertyDescriptor _pd; + private DetailsURL _detailsUrl; + private SchemaKey _schemaKey; + private String _queryName; + private FieldKey _pkFieldKey; + private FieldKey _objectURIFieldKey; + + public Factory(PropertyDescriptor pd, Container c, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) + { + _pd = pd; + _container = c; + _schemaKey = schemaKey; + _queryName = queryName; + _pkFieldKey = pkFieldKey; + } + + public Factory(PropertyDescriptor pd, Container c, @NotNull FieldKey lsidColumnFieldKey) + { + _pd = pd; + _container = c; + _objectURIFieldKey = lsidColumnFieldKey; + } + + public Factory(DetailsURL detailsURL, Container c, @NotNull FieldKey pkFieldKey) + { + _detailsUrl = detailsURL; + _container = c; + _pkFieldKey = pkFieldKey; + } + + @Override + public Factory remapFieldKeys(@Nullable FieldKey parent, @Nullable Map remap) + { + Factory remapped = this.clone(); + if (remapped._pkFieldKey != null) + { + remapped._pkFieldKey = FieldKey.remap(_pkFieldKey, parent, remap); + if (null == remapped._pkFieldKey) + remapped._pkFieldKey = _pkFieldKey; + } + if (remapped._objectURIFieldKey != null) + { + remapped._objectURIFieldKey = FieldKey.remap(_objectURIFieldKey, parent, remap); + if (null == remapped._objectURIFieldKey) + remapped._objectURIFieldKey = _objectURIFieldKey; + } + return remapped; + } + + @Override + public DisplayColumn createRenderer(ColumnInfo col) + { + if (_pd == null && _detailsUrl != null) + return new FileLinkDisplayColumn(col, _detailsUrl, _container, _pkFieldKey); + else if (_pkFieldKey != null) + return new FileLinkDisplayColumn(col, _pd, _container, _schemaKey, _queryName, _pkFieldKey); + else if (_container != null) + return new FileLinkDisplayColumn(col, _pd, _container, _objectURIFieldKey); + else + throw new IllegalArgumentException("Cannot create a renderer from the specified configuration properties"); + } + + @Override + public FileLinkDisplayColumn.Factory clone() + { + try + { + return (Factory)super.clone(); + } + catch (CloneNotSupportedException e) + { + throw new RuntimeException(e); + } + } + } + + private final Container _container; + + private FieldKey _pkFieldKey; + private FieldKey _objectURIFieldKey; + + /** Use schemaName/queryName and pk FieldKey value to resolve File in CoreController.DownloadFileLinkAction. */ + public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) + { + super(col); + _container = container; + _pkFieldKey = pkFieldKey; + + if (pd.getURL() == null) + { + // Don't stomp over an explicitly configured URL on this column + StringBuilder sb = new StringBuilder("/core/downloadFileLink.view?propertyId="); + sb.append(pd.getPropertyId()); + sb.append("&schemaName="); + sb.append(PageFlowUtil.encodeURIComponent(schemaKey.toString())); + sb.append("&queryName="); + sb.append(PageFlowUtil.encodeURIComponent(queryName)); + sb.append("&pk=${"); + sb.append(pkFieldKey); + sb.append("}"); + sb.append("&modified=${Modified}"); + if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) + { + sb.append("&inline=false"); + } + ContainerContext context = new ContainerContext.FieldKeyContext(new FieldKey(pkFieldKey.getParent(), "Folder")); + setURLExpression(DetailsURL.fromString(sb.toString(), context)); + } + } + + /** Use LSID FieldKey value as ObjectURI to resolve File in CoreController.DownloadFileLinkAction. */ + public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull FieldKey objectURIFieldKey) + { + super(col); + _container = container; + _objectURIFieldKey = objectURIFieldKey; + + if (pd.getURL() == null) + { + // Don't stomp over an explicitly configured URL on this column + + ActionURL baseUrl = PageFlowUtil.urlProvider(CoreUrls.class).getDownloadFileLinkBaseURL(container, pd); + if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) + { + baseUrl.addParameter("inline", "false"); + } + else + { + setLinkTarget("_blank"); + } + DetailsURL url = new DetailsURL(baseUrl, "objectURI", objectURIFieldKey); + setURLExpression(url); + } + } + + public FileLinkDisplayColumn(ColumnInfo col, DetailsURL detailsURL, Container container, @NotNull FieldKey pkFieldKey) + { + super(col); + _container = container; + _pkFieldKey = pkFieldKey; + + setURLExpression(detailsURL); + } + + @Override + protected Object getInputValue(RenderContext ctx) + { + ColumnInfo col = getColumnInfo(); + Object val = null; + TableViewForm viewForm = ctx.getForm(); + + if (col != null) + { + if (null != viewForm && viewForm.contains(this, ctx)) + { + val = viewForm.getAsString(getFormFieldName(ctx)); + } + else if (ctx.getRow() != null) + val = col.getValue(ctx); + } + + return val; + } + + @Override + public void addQueryFieldKeys(Set keys) + { + super.addQueryFieldKeys(keys); + keys.add(FieldKey.fromParts("Modified")); + if (_pkFieldKey != null) + keys.add(_pkFieldKey); + if (_objectURIFieldKey != null) + keys.add(_objectURIFieldKey); + } + + public static boolean filePathExist(String path, Container container, User user) + { + String davPath = path; + if (FileUtil.isUrlEncoded(davPath)) + davPath = FileUtil.decodeURL(davPath); + var resolver = WebdavService.get().getResolver(); + // Resolve path under webdav root + Path parsed = Path.parse(StringUtils.trim(davPath)); + + // Issue 52968: handle context path + Path contextPath = AppProps.getInstance().getParsedContextPath(); + if (parsed.startsWith(contextPath)) + parsed = parsed.subpath(contextPath.size(), parsed.size()); + + WebdavResource resource = resolver.lookup(parsed); + if ((null == resource || !resource.exists()) && !parsed.startsWith(new Path("_webdav"))) + resource = resolver.lookup(new Path("_webdav").append(parsed)); + if (resource != null && resource.isFile() && resource.canRead(user, true)) + { + return true; + } + else + { + // Resolve file under pipeline root + PipeRoot root = PipelineService.get().findPipelineRoot(container); + if (root != null) + { + // Attempt absolute path first, then relative path from pipeline root + File f = new File(path); + if (!root.isUnderRoot(f)) + f = root.resolvePath(path); + + return (NetworkDrive.exists(f) && root.isUnderRoot(f) && root.hasPermission(container, user, ReadPermission.class)); + } + } + + return false; + } + + @Override + protected String getFileName(RenderContext ctx, Object value) + { + return getFileName(ctx, value, false); + } + + @Override + protected String getFileName(RenderContext ctx, Object value, boolean isDisplay) + { + String result = value == null ? null : StringUtils.trimToNull(value.toString()); + if (result != null) + { + File f = null; + if (result.startsWith("file:")) + { + try + { + f = new File(new URI(result)); + } + catch (URISyntaxException x) + { + // try to recover + result = result.substring("file:".length()); + } + } + if (null == f) + f = FileUtil.getAbsoluteCaseSensitiveFile(new File(result)); + NetworkDrive.ensureDrive(f.getPath()); + List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); + boolean valid = false; + List containers = new ArrayList<>(); + containers.add(_container); + // Not ideal, but needed in case data is queried from cross folder context + if (ctx.get("folder") != null || ctx.get("container") != null) + { + Object folderObj = ctx.get("folder"); + if (folderObj == null) + folderObj = ctx.get("container"); + if (folderObj instanceof String containerId) + { + Container dataContainer = ContainerManager.getForId(containerId); + if (dataContainer != null && !dataContainer.equals(_container)) + containers.add(dataContainer); + } + } + for (Container container : containers) + { + if (valid) + break; + + for (FileContentService.ContentType fileRootType : fileRootTypes) + { + result = relativize(f, FileContentService.get().getFileRoot(container, fileRootType)); + if (result != null) + { + // Issue 54062: Strip folder name from displayed name + if (isDisplay) + result = f.getName(); + + valid = true; + break; + } + } + } + if (result == null) + { + result = f.getName(); + } + + if ((!valid || !f.exists()) && !result.endsWith(UNAVAILABLE_FILE_SUFFIX)) + result += UNAVAILABLE_FILE_SUFFIX; + } + return result; + } + + public static String relativize(File f, File fileRoot) + { + if (fileRoot != null) + { + NetworkDrive.ensureDrive(fileRoot.getPath()); + fileRoot = FileUtil.getAbsoluteCaseSensitiveFile(fileRoot); + if (URIUtil.isDescendant(fileRoot.toURI(), f.toURI())) + { + try + { + return FileUtil.relativize(fileRoot, f, false); + } + catch (IOException ignored) {} + } + } + return null; + } + + @Override + protected InputStream getFileContents(RenderContext ctx, Object ignore) throws FileNotFoundException + { + Object value = getValue(ctx); + String s = value == null ? null : StringUtils.trimToNull(value.toString()); + if (s != null) + { + File f = new File(s); + if (f.isFile()) + return new FileInputStream(f); + } + return null; + } + + @Override + protected void renderIconAndFilename( + RenderContext ctx, + HtmlWriter out, + String fileValue /*Could be raw path value, or processed filename by `getFileName`*/, + boolean link, + boolean thumbnail) + { + Object value = getValue(ctx); + String strValue = value == null ? null : StringUtils.trimToNull(value.toString()); + if (strValue != null && !fileValue.endsWith(UNAVAILABLE_FILE_SUFFIX)) + { + File f; + if (strValue.startsWith("file:")) + f = new File(URI.create(strValue)); + else + f = new File(strValue); + + if (!f.exists()) + { + // try all file root + List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); + for (FileContentService.ContentType fileRootType : fileRootTypes) + { + String fullPath = FileContentService.get().getFileRoot(_container, fileRootType).getAbsolutePath()+ File.separator + value; + f = new File(fullPath); + if (f.exists()) + break; + } + } + + // It's probably a file, so check that first + if (f.isFile()) + { + super.renderIconAndFilename(ctx, out, strValue, link, thumbnail); + } + else if (f.isDirectory()) + { + super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(".folder"), null, link, false); + } + else + { + // It's not on the file system anymore, so don't offer a link and tell the user it's unavailable + super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(fileValue), null, false, false); + } + } + else + { + super.renderIconAndFilename(ctx, out, fileValue, link, thumbnail); + } + } + + @Override + public Object getDisplayValue(RenderContext ctx) + { + return getFileName(ctx, super.getDisplayValue(ctx), true); + } + + @Override + public Object getJsonValue(RenderContext ctx) + { + return getFileName(ctx, super.getDisplayValue(ctx)); + } + + @Override + public Object getExportCompatibleValue(RenderContext ctx) + { + return getJsonValue(ctx); + } + + @Override + public boolean isFilterable() + { + return true; + } + @Override + public boolean isSortable() + { + return true; + } + +} diff --git a/api/webapp/clientapi/ext3/FilterDialog.js b/api/webapp/clientapi/ext3/FilterDialog.js index 04bf945459f..175766ec4f9 100644 --- a/api/webapp/clientapi/ext3/FilterDialog.js +++ b/api/webapp/clientapi/ext3/FilterDialog.js @@ -1,1751 +1,1754 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -const CONCEPT_CODE_CONCEPT_URI = 'http://www.labkey.org/types#conceptCode'; - -LABKEY.FilterDialog = Ext.extend(Ext.Window, { - - autoHeight: true, - - bbarCfg : { - bodyStyle : 'border-top: 1px solid black;' - }, - - cls: 'labkey-filter-dialog', - - closeAction: 'destroy', - - defaults: { - border: false, - msgTarget: 'under' - }, - - itemId: 'filterWindow', - - modal: true, - - resizable: false, - - // 24846 - width: Ext.isGecko ? 425 : 410, - - allowFacet : undefined, - - cacheFacetResults: true, - - hasOntologyModule: false, - - initComponent : function() { - - if (!this['dataRegionName']) { - console.error('dataRegionName is required for a LABKEY.FilterDialog'); - return; - } - - this.column = this.column || this.boundColumn; // backwards compat - if (!this.configureColumn(this.column)) { - return; - } - - this.hasOntologyModule = LABKEY.moduleContext.api.moduleNames.indexOf('ontology') > -1; - - Ext.apply(this, { - title: this.title || "Show Rows Where " + this.column.caption + "...", - - carryfilter : true, // whether filter state should try to be carried between views (e.g. when changing tabs) - - // buttons - buttons: this.configureButtons(), - - // hook key events - keys:[{ - key: Ext.EventObject.ENTER, - handler: this.onKeyEnter, - scope: this - },{ - key: Ext.EventObject.ESC, - handler: this.closeDialog, - scope: this - }], - width: this.isConceptColumnFilter() ? - (Ext.isGecko ? 613 : 598) : - // 24846 - (Ext.isGecko ? 505 : 490), - // listeners - listeners: { - destroy: function() { - if (this.focusTask) { - Ext.TaskMgr.stop(this.focusTask); - } - }, - resize : function(panel) { panel.syncShadow(); }, - scope : this - } - }); - - this.items = [this.getContainer()]; - - LABKEY.FilterDialog.superclass.initComponent.call(this); - }, - - allowFaceting : function() { - if (Ext.isDefined(this.allowFacet)) - return this.allowFacet; - - var dr = this.getDataRegion(); - if (!this.isQueryDataRegion(dr)) { - this.allowFacet = false; - return this.allowFacet; - } - - this.allowFacet = false; - switch (this.column.facetingBehaviorType) { - - case 'ALWAYS_ON': - this.allowFacet = true; - break; - case 'ALWAYS_OFF': - this.allowFacet = false; - break; - case 'AUTOMATIC': - // auto rules are if the column is a lookup or dimension - // OR if it is of type : (boolean, int, date, text), multiline excluded - if (this.column.lookup || this.column.dimension) - this.allowFacet = true; - else if (this.jsonType == 'boolean' || this.jsonType == 'int' || - (this.jsonType == 'string' && this.column.inputType != 'textarea')) - this.allowFacet = true; - break; - } - - return this.allowFacet; - }, - - // Returns an Array of button configurations based on supported operations on this column - configureButtons : function() { - var buttons = [ - {text: 'OK', handler: this.onApply, scope: this}, - {text: 'Cancel', handler: this.closeDialog, scope: this} - ]; - - if (this.getDataRegion()) { - buttons.push({text: 'Clear Filter', handler: this.clearFilter, scope: this}); - buttons.push({text: 'Clear All Filters', handler: this.clearAllFilters, scope: this}); - } - - return buttons; - }, - - // Returns true if the initialization was a success - configureColumn : function(column) { - if (!column) { - console.error('A column is required for LABKEY.FilterDialog'); - return false; - } - - Ext.apply(this, { - // DEPRECATED: Either invoked from GWT, which will handle the commit itself. - // Or invoked as part of a regular filter dialog on a grid - changeFilterCallback: this.confirmCallback, - - fieldCaption: column.caption, - fieldKey: column.lookup && column.displayField ? column.displayField : column.fieldKey, // terrible - jsonType: (column.displayFieldJsonType ? column.displayFieldJsonType : column.jsonType) || 'string' - }); - - return true; - }, - - onKeyEnter : function() { - var view = this.getContainer().getActiveTab(); - var filters = view.getFilters() - if (filters && filters.length > 0) { - var hasMultiValueFilter = false; - filters.forEach(filter => { - var urlSuffix = filter.getFilterType().getURLSuffix(); - if (filter.getFilterType().isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) - hasMultiValueFilter = true; - }) - if (hasMultiValueFilter) - return; - } - - - this.onApply(); - }, - - hasMultiValueFilter: function() { - this._getFilters() - }, - - onApply : function() { - if (this.apply()) - this.closeDialog(); - }, - - // Validates and applies the current filter(s) to the DataRegion - apply : function() { - var view = this.getContainer().getActiveTab(); - var isValid = true; - - if (!view.getForm().isValid()) - isValid = false; - - if (isValid) { - isValid = view.checkValid(); - } - - if (isValid) { - - var dr = this.getDataRegion(), - filters = view.getFilters(); - - if (Ext.isFunction(this.changeFilterCallback)) { - - var filterParams = '', sep = ''; - for (var f=0; f < filters.length; f++) { - filterParams += sep + encodeURIComponent(filters[f].getURLParameterName(this.dataRegionName)) + '=' + encodeURIComponent(filters[f].getURLParameterValue()); - sep = '&'; - } - this.changeFilterCallback.call(this, null, null, filterParams); - } - else { - if (filters.length > 0) { - // add the current filter(s) - if (view.supportsMultipleFilters) { - dr.replaceFilters(filters, this.column); - } - else - dr.replaceFilter(filters[0]); - } - else { - this.clearFilter(); - } - } - } - - return isValid; - }, - - clearFilter : function() { - var dr = this.getDataRegion(); - if (!dr) { return; } - Ext.StoreMgr.clear(); - dr.clearFilter(this.fieldKey); - this.closeDialog(); - }, - - clearAllFilters : function() { - var dr = this.getDataRegion(); - if (!dr) { return; } - dr.clearAllFilters(); - this.closeDialog(); - }, - - closeDialog : function() { - this.close(); - }, - - getDataRegion : function() { - return LABKEY.DataRegions[this.dataRegionName]; - }, - - isQueryDataRegion : function(dr) { - return dr && dr.schemaName && dr.queryName; - }, - - // Returns a class instance of a class that extends Ext.Container. - // This container will hold all the views registered to this FilterDialog instance. - // For caching purposes assign to this.viewcontainer - getContainer : function() { - - if (!this.viewcontainer) { - - var views = this.getViews(); - var type = 'TabPanel'; - - if (views.length == 1) { - views[0].title = false; - type = 'Panel'; - } - - var config = { - defaults: this.defaults, - deferredRender: false, - monitorValid: true, - - // sizing and styling - autoHeight: true, - bodyStyle: 'margin: 0 5px;', - border: true, - items: views - }; - - if (type == 'TabPanel') { - config.listeners = { - beforetabchange : function(tp, newTab, oldTab) { - if (this.carryfilter && newTab && oldTab && oldTab.isChanged()) { - newTab.setFilters(oldTab.getFilters()); - } - }, - tabchange : function() { - this.syncShadow(); - this.viewcontainer.getActiveTab().doLayout(); // required when facets return while on another tab - }, - scope : this - }; - } - - if (views.length > 1) { - config.activeTab = this.getDefaultTab(); - } - else { - views[0].title = false; - } - - this.viewcontainer = new Ext[type](config); - - if (!Ext.isFunction(this.viewcontainer.getActiveTab)) { - var me = this; - this.viewcontainer.getActiveTab = function() { - return me.viewcontainer.items.items[0]; - }; - // views attempt to hook the 'activate' event but some panel types do not fire - // force fire on the first view - this.viewcontainer.items.items[0].on('afterlayout', function(p) { - p.fireEvent('activate', p); - }, this, {single: true}); - } - } - - return this.viewcontainer; - }, - - _getFilters : function() { - var filters = []; - - var dr = this.getDataRegion(); - if (dr) { - Ext.each(dr.getUserFilterArray(), function(ff) { - if (this.column.lookup && this.column.displayField && ff.getColumnName().toLowerCase() === this.column.displayField.toLowerCase()) { - filters.push(ff); - } - else if (this.column.fieldKey && ff.getColumnName().toLowerCase() === this.column.fieldKey.toLowerCase()) { - filters.push(ff); - } - }, this); - } - else if (this.queryString) { // deprecated - filters = LABKEY.Filter.getFiltersFromUrl(this.queryString, this.dataRegionName); - } - - return filters; - }, - - getDefaultTab: function() { - return this.isConceptColumnFilter() ? - 0 : (this.allowFaceting() ? 1 : 0); - }, - - isConceptColumnFilter: function() { - return this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI && this.hasOntologyModule; - }, - - getDefaultView: function(filters) { - const xtypeVal = this.isConceptColumnFilter() - ? 'filter-view-conceptfilter' - : 'filter-view-default'; - - return { - xtype: xtypeVal, - column: this.column, - fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly - dataRegionName: this.dataRegionName, - jsonType : this.jsonType, - filters: filters - }; - }, - - // Override to return your own filter views - getViews : function() { - - const filters = this._getFilters(), views = []; - - // default view - views.push(this.getDefaultView(filters)); - - // facet view - if (this.allowFaceting()) { - views.push({ - xtype: 'filter-view-faceted', - column: this.column, - fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly - dataRegionName: this.dataRegionName, - jsonType : this.jsonType, - filters: filters, - cacheResults: this.cacheFacetResults, - listeners: { - invalidfacetedfilter : function() { - this.carryfilter = false; - this.getContainer().setActiveTab(0); - this.getContainer().getActiveTab().doLayout(); - this.carryfilter = true; - }, - scope: this - }, - scope: this - }) - } - - return views; - } -}); - -LABKEY.FilterDialog.ViewPanel = Ext.extend(Ext.form.FormPanel, { - - supportsMultipleFilters: false, - - filters : [], - - changed : false, - - initComponent : function() { - if (!this['dataRegionName']) { - console.error('dataRegionName is required for a LABKEY.FilterDialog.ViewPanel'); - return; - } - LABKEY.FilterDialog.ViewPanel.superclass.initComponent.call(this); - }, - - // Override to provide own view validation - checkValid : function() { - return true; - }, - - getDataRegion : function() { - return LABKEY.DataRegions[this.dataRegionName]; - }, - - getFilters : function() { - console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement getFilters()'); - }, - - setFilters : function(filterArray) { - console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement setFilters(filterArray)'); - }, - - getXtypes : function() { - const textInputTypes = ['textfield', 'textarea']; - switch (this.jsonType) { - case "date": - return ["datefield"]; - case "int": - case "float": - return textInputTypes; - case "boolean": - return ['labkey-booleantextfield']; - default: - return textInputTypes; - } - }, - - // Returns true if a view has been altered since the last time it was activated - isChanged : function() { - return this.changed; - } -}); - -Ext.ns('LABKEY.FilterDialog.View'); - -LABKEY.FilterDialog.View.Default = Ext.extend(LABKEY.FilterDialog.ViewPanel, { - - supportsMultipleFilters: true, - - itemDefaults: { - border: false, - msgTarget: 'under' - }, - - initComponent : function() { - - Ext.apply(this, { - autoHeight: true, - title: this.title === false ? false : 'Choose Filters', - bodyStyle: 'padding: 5px;', - bubbleEvents: ['add', 'remove', 'clientvalidation'], - defaults: { border: false }, - items: this.generateFilterDisplays(2) - }); - - this.combos = []; - this.inputs = []; - - LABKEY.FilterDialog.View.Default.superclass.initComponent.call(this); - - this.on('activate', this.onViewReady, this, {single: true}); - }, - - updateViewReady: function(f) { - var filter = this.filters[f]; - var combo = this.combos[f]; - - // Update the input enabled/disabled status by using the 'select' event listener on the combobox. - // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. - var store = combo.getStore(); - var filterType = filter.getFilterType(); - var urlSuffix = filterType.getURLSuffix(); - if (store) { - var rec = store.getAt(store.find('value', urlSuffix)); - if (rec) { - combo.setValue(urlSuffix); - combo.fireEvent('select', combo, rec); - } - } - - var inputValue = filter.getURLParameterValue(); - - if (this.jsonType === "date" && inputValue) { - const dateVal = Date.parseDate(inputValue, LABKEY.extDateInputFormat); // date inputs are formatted to ISO date format on server - inputValue = dateVal.format(LABKEY.extDefaultDateFormat); // convert back to date field accepted format for render - } - - // replace multivalued separator (i.e. ;) with \n on UI - if (filterType.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { - var valueSeparator = filterType.getMultiValueSeparator(); - if (typeof inputValue === 'string' && inputValue.indexOf('\n') === -1 && inputValue.indexOf(valueSeparator) > 0) { - inputValue = filterType.parseValue(inputValue); - if (LABKEY.Utils.isArray(inputValue)) - inputValue = inputValue.join('\n'); - } - } - - var inputs = this.getVisibleInputs(); - if (inputs[f]) { - inputs[f].setValue(inputValue); - } - }, - - onViewReady : function() { - var inputs = this.getVisibleInputs(); - if (this.filters.length == 0) { - for (var c=0; c < this.combos.length; c++) { - // Update the input enabled/disabled status by using the 'select' event listener on the combobox. - // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. - this.combos[c].reset(); - this.combos[c].fireEvent('select', this.combos[c], null); - if (inputs[c]) { - inputs[c].reset(); - } - } - } - else { - for (var f=0; f < this.filters.length; f++) { - if (f < this.combos.length) { - this.updateViewReady(f); - } - } - } - - //Issue 24550: always select the first filter field, and also select text if present - if (inputs[0]) { - inputs[0].focus(true, 100, inputs[0]); - } - - this.changed = false; - }, - - getVisibleInputs: function() { - return this.inputs.filter(input => !input.hidden); - }, - - checkValid : function() { - var combos = this.combos; - var inputs = this.getVisibleInputs(), input, value, f; - - var isValid = true; - - Ext.each(combos, function(c, i) { - if (!c.isValid()) { - isValid = false; - } - else { - input = inputs[i]; - value = input.getValue(); - - f = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); - - if (!f) { - alert('filter not found: ' + c.getValue()); - return; - } - - if (f.isDataValueRequired() && Ext.isEmpty(value)) { - input.markInvalid('You must enter a value'); - isValid = false; - } - } - }); - - return isValid; - }, - - inputFieldValidator : function(input, combo) { - - var store = combo.getStore(); - if (store) { - var rec = store.getAt(store.find('value', combo.getValue())); - var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); - - if (rec) { - if (filter.isMultiValued()) - return this.validateMultiValueInput(input.getValue(), filter.getMultiValueSeparator(), filter.getMultiValueMinOccurs(), filter.getMultiValueMaxOccurs()); - return this.validateInputField(input.getValue()); - } - } - return true; - }, - - addFilterConfig: function(idx, items) { - var subItems = [this.getComboConfig(idx)]; - var inputConfigs = this.getInputConfigs(idx); - inputConfigs.forEach(config => { - subItems.push(config); - }); - items.push({ - xtype: 'panel', - layout: 'form', - itemId: 'filterPair' + idx, - border: false, - defaults: this.itemDefaults, - items: subItems, - scope: this - }); - }, - - generateFilterDisplays : function(quantity) { - var idx = this.nextIndex(), items = [], i=0; - - for(; i < quantity; i++) { - this.addFilterConfig(idx, items); - - idx++; - } - - return items; - }, - - getDefaultFilterType: function(idx) { - return idx === 0 ? LABKEY.Filter.getDefaultFilterForType(this.jsonType).getURLSuffix() : ''; - }, - - getComboConfig : function(idx) { - var val = this.getDefaultFilterType(idx); - - return { - xtype: 'combo', - itemId: 'filterComboBox' + idx, - filterIndex: idx, - name: 'filterType_'+(idx + 1), //for compatibility with tests... - listWidth: (this.jsonType == 'date' || this.jsonType == 'boolean') ? null : 380, - emptyText: idx === 0 ? 'Choose a filter:' : 'No other filter', - autoSelect: false, - width: 330, - minListWidth: 330, - triggerAction: 'all', - fieldLabel: (idx === 0 ?'Filter Type' : 'and'), - store: this.getSelectionStore(idx), - displayField: 'text', - valueField: 'value', - typeAhead: 'false', - forceSelection: true, - mode: 'local', - clearFilterOnReset: false, - editable: false, - value: val, - originalValue: val, - listeners : { - render : function(combo) { - this.combos.push(combo); - // Update the associated inputField's enabled/disabled state on initial render - this.enableInputField(combo); - }, - select : function (combo) { - this.changed = true; - this.enableInputField(combo); - }, - scope: this - }, - scope: this - }; - }, - - enableInputField : function (combo) { - - var idx = combo.filterIndex; - var inputField = this.find('itemId', 'inputField'+idx+'-0')[0]; - var textAreaField = this.find('itemId', 'inputField'+idx+'-1')[0]; - - const urlSuffix = combo.getValue().toLowerCase(); - var filter = LABKEY.Filter.getFilterTypeForURLSuffix(urlSuffix); - var selectedValue = filter ? filter.getURLSuffix() : ''; - - var combos = this.combos; - var inputFields = this.inputs; - - if (filter && !filter.isDataValueRequired()) { - //Disable the field and allow it to be blank for values 'isblank' and 'isnonblank'. - inputField.disable(); - inputField.setValue(); - inputField.blur(); - if (textAreaField) - { - textAreaField.disable(); - textAreaField.setValue(); - textAreaField.blur(); - } - } - else { - if (filter.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { - textAreaField.show(); - textAreaField.enable(); - textAreaField.setValue(inputField.getValue()); - textAreaField.validate(); - textAreaField.focus('', 50); - inputField.hide(); - } - else { - inputField.show(); - inputField.enable(); - inputField.setValue(textAreaField && textAreaField.getValue()); - inputField.validate(); - inputField.focus('', 50); - textAreaField && textAreaField.hide(); - } - } - - //if the value is null, this indicates no filter chosen. if it lacks an operator (ie. isBlank) - //in either case, this means we should disable all other filters - if(selectedValue == '' || !filter.isDataValueRequired()){ - //Disable all subsequent combos - Ext.each(combos, function(combo, idx) { - //we enable the next combo in the series - if(combo.filterIndex == this.filterIndex + 1){ - combo.setValue(); - inputFields[idx].setValue(); - inputFields[idx].enable(); - inputFields[idx].validate(); - inputFields[idx].blur(); - } - else if (combo.filterIndex > this.filterIndex){ - combo.setValue(); - inputFields[idx].disable(); - } - - }, this); - } - else{ - //enable the other filterComboBoxes. - Ext.each(combos, function(combo, i) { combo.enable(); }, this); - - if (combos.length) { - combos[0].focus('', 50); - } - } - }, - - getFilters : function() { - - var inputs = this.getVisibleInputs(); - var combos = this.combos; - var value, type, filters = []; - - Ext.each(combos, function(c, i) { - if (!inputs[i].disabled || (c.getRawValue() != 'No Other Filter')) { - value = inputs[i].getValue(); - type = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); - - if (!type) { - alert('Filter not found for suffix: ' + c.getValue()); - } - - // Issue 52068: for multivalued filter types, split on new line to get an array of values - if (value && type.isMultiValued()) { - value = value.indexOf('\n') > -1 ? value.split('\n') : type.parseValue(value); - } - - filters.push(LABKEY.Filter.create(this.fieldKey, value, type)); - } - }, this); - - return filters; - }, - - getAltDateFormats: function() { - if (this.jsonType === "date") - return 'Y-m-d|' + LABKEY.Utils.getDateAltFormats(); // always support ISO - return undefined; - }, - - getInputConfigs : function(idx) { - var me = this; - const xTypes = this.getXtypes(); - var configs = []; - xTypes.forEach((xType, typeId) => { - var config = { - xtype : xType, - itemId : 'inputField' + idx + '-' + typeId, - filterIndex : idx, - id : 'value_'+(idx + 1) + (typeId ? '-' + typeId: ''), //for compatibility with tests... - width : 330, - blankText : 'You must enter a value.', - validateOnBlur: true, - value : null, - altFormats : this.getAltDateFormats(), - hidden: typeId === 1, - disabled: typeId === 1, - emptyText: xType === 'textarea' ? 'Use new line or semicolon to separate entries' : (me.jsonType === 'time' ? 'HH:mm:ss' : undefined), - style: { resize: 'none' }, - validator : function(value) { - - // support for filtering '∞' - if (me.jsonType == 'float' && value.indexOf('∞') > -1) { - value = value.replace('∞', 'Infinity'); - this.setRawValue(value); // does not fire validation - } - - var combos = me.combos; - if (!combos.length) { - return; - } - - return me.inputFieldValidator(this, combos[idx]); - }, - listeners: { - disable : function(field){ - //Call validate after disable so any pre-existing validation errors go away. - if(field.rendered) { - field.validate(); - } - }, - focus : function(f) { - if (this.focusTask) { - Ext.TaskMgr.stop(this.focusTask); - } - }, - render : function(input) { - me.inputs.push(input); - if (!me.focusReady) { - me.focusReady = true; - // create a task to set the input focus that will get started after layout is complete, - // the task will run for a max of 2000ms but will get stopped when the component receives focus - this.focusTask = {interval:150, run: function(){ - input.focus(null, 50); - Ext.TaskMgr.stop(this.focusTask); - }, scope: this, duration: 2000}; - } - }, - change : this.inputListener, - scope : this - }, - scope: this - }; - if (this.jsonType === "date") { - config.format = LABKEY.extDefaultDateFormat; - - // default invalidText : "{0} is not a valid date - it must be in the format {1}", - // override the default warning msg as there is one preferred format, but there are also a set of acceptable altFormats - config.invalidText = "{0} might not be a valid date - the preferred format is {1}"; - } - - configs.push(config); - }) - return configs; - }, - - inputListener : function(input, newVal, oldVal) { - if (oldVal != newVal) { - this.changed = true; - } - }, - - getFilterTypes: function() { - return LABKEY.Filter.getFilterTypesForType(this.jsonType, this.column.mvEnabled); - }, - - getSelectionStore : function(storeNum) { - var fields = ['text', 'value', - {name: 'isMulti', type: Ext.data.Types.BOOL}, - {name: 'isOperatorOnly', type: Ext.data.Types.BOOL} - ]; - var store = new Ext.data.ArrayStore({ - fields: fields, - idIndex: 1 - }); - var comboRecord = Ext.data.Record.create(fields); - - var filters = this.getFilterTypes(); - - for (var i=0; i 0) { - store.removeAt(0); - store.insert(0, new comboRecord({text:'No Other Filter', value: ''})); - } - - return store; - }, - - setFilters : function(filterArray) { - this.filters = filterArray; - this.onViewReady(); - }, - - nextIndex : function() { - return 0; - }, - - validateMultiValueInput : function(inputValues, multiValueSeparator, minOccurs, maxOccurs) { - if (!inputValues) - return true; - // Used when "Equals One Of.." or "Between" is selected. Calls validateInputField on each value entered. - const sep = inputValues.indexOf('\n') > 0 ? '\n' : multiValueSeparator; - var values = inputValues.split(sep); - var isValid = ""; - for(var i = 0; i < values.length; i++){ - isValid = this.validateInputField(values[i]); - if(isValid !== true){ - return isValid; - } - } - - if (minOccurs !== undefined && minOccurs > 0) - { - if (values.length < minOccurs) - return "At least " + minOccurs + " '" + multiValueSeparator + "' separated values are required"; - } - - if (maxOccurs !== undefined && maxOccurs > 0) - { - if (values.length > maxOccurs) - return "At most " + maxOccurs + " '" + multiValueSeparator + "' separated values are allowed"; - } - - if (!Ext.isEmpty(inputValues) && typeof inputValues === 'string' && inputValues.trim().length > 2000) - return "Value is too long"; - - //If we make it out of the for loop we had no errors. - return true; - }, - - // The fact that Ext3 ties validation to the editor is a little funny, - // but using this shifts the work to Ext - validateInputField : function(value) { - var map = { - 'string': 'STRING', - 'time': 'STRING', - 'int': 'INT', - 'float': 'FLOAT', - 'date': 'DATE', - 'boolean': 'BOOL' - }; - var type = map[this.jsonType]; - if (type) { - var field = new Ext.data.Field({ - type: Ext.data.Types[type], - allowDecimals : this.jsonType != "int", //will be ignored by anything besides numberfield - useNull: true - }); - - var values = (!Ext.isEmpty(value) && typeof value === 'string' && value.indexOf('\n') > -1) ? value.split('\n') : [value]; - var invalid = null; - values.forEach(val => { - if (val == null) - return; - var convertedVal = field.convert(val); - if (!Ext.isEmpty(val) && val != convertedVal) { - invalid = val; - } - }) - - if (invalid != null) - return "Invalid value: " + invalid; - - if (!Ext.isEmpty(value) && typeof value === 'string' && value.trim().length > 2000) - return "Value is too long"; - } - else { - if (this.jsonType.toLowerCase() !== 'array') - console.log('Unrecognized type: ' + this.jsonType); - } - - return true; - } -}); - -Ext.reg('filter-view-default', LABKEY.FilterDialog.View.Default); - -LABKEY.FilterDialog.View.Faceted = Ext.extend(LABKEY.FilterDialog.ViewPanel, { - - MAX_FILTER_CHOICES: 250, // This is the maximum number of filters that will be requested / shown - - applyContextFilters: true, - - /** - * Logically convert filters to try and optimize the query on the server. - * (e.g. using NOT IN when less than half the available values are checked) - */ - filterOptimization: true, - - cacheResults: true, - - emptyDisplayValue: '[Blank]', - - gridID: Ext.id(), - - loadError: undefined, - - overflow: false, - - initComponent : function() { - - Ext.apply(this, { - title : 'Choose Values', - border : false, - height : 200, - bodyStyle: 'overflow-x: hidden; overflow-y: auto', - bubbleEvents: ['add', 'remove', 'clientvalidation'], - defaults : { - border : false - }, - markDisabled : true, - items: [{ - layout: 'hbox', - style: 'padding-bottom: 5px; overflow-x: hidden', - defaults: { - border: false - }, - items: [{ - xtype: 'box', - cls: 'alert alert-danger', - hidden: true, - id: this.gridID + '-error', - style: 'position: relative;', - },{ - xtype: 'label', - id: this.gridID + 'OverflowLabel', - hidden: true, - text: 'There are more than ' + this.MAX_FILTER_CHOICES + ' values. Showing a partial list.' - }] - }] - }); - - LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this); - - this.on('render', this.onPanelRender, this, {single: true}); - }, - - formatValue : function(val) { - if(this.column) { - if (this.column.extFormatFn) { - try { - this.column.extFormatFn = eval(this.column.extFormatFn); - } - catch (error) { - console.log('improper extFormatFn: ' + this.column.extFormatFn); - } - - if (Ext.isFunction(this.column.extFormatFn)) { - val = this.column.extFormatFn(val); - } - } - else if (this.jsonType == 'int') { - val = parseInt(val); - } - } - return val; - }, - - // copied from Ext 4 Ext.Array.difference - difference : function(arrayA, arrayB) { - var clone = arrayA.slice(), - ln = clone.length, - i, j, lnB; - - for (i = 0,lnB = arrayB.length; i < lnB; i++) { - for (j = 0; j < ln; j++) { - if (clone[j] === arrayB[i]) { - clone.splice(j, 1); - j--; - ln--; - } - } - } - - return clone; - }, - - constructFilter : function(selected, unselected) { - var filter = null; - - if (selected.length > 0) { - - var columnName = this.fieldKey; - - // one selection - if (selected.length == 1) { - if (selected[0].get('displayValue') == this.emptyDisplayValue) - filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK); - else - filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL - } - else if (this.filterOptimization && selected.length > unselected.length) { - // Do the negation - if (unselected.length == 1) { - var val = unselected[0].get('value'); - var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING); - - // 18716: Check if 'unselected' contains empty value - filter = LABKEY.Filter.create(columnName, val, type); - } - else - filter = LABKEY.Filter.create(columnName, this.selectedToValues(unselected), LABKEY.Filter.Types.NOT_IN); - } - else { - filter = LABKEY.Filter.create(columnName, this.selectedToValues(selected), LABKEY.Filter.Types.IN); - } - } - - return filter; - }, - - // get array of values from the selected store item array - selectedToValues : function(valueArray) { - return valueArray.map(function (i) { return i.get('value'); }); - }, - - // Implement interface LABKEY.FilterDialog.ViewPanel - getFilters : function() { - var grid = Ext.getCmp(this.gridID); - var filters = []; - - if (grid) { - var store = grid.store; - var count = store.getCount(); // TODO: Check if store loaded - var selected = grid.getSelectionModel().getSelections(); - - if (count == 0 || selected.length == 0 || selected.length == count) { - filters = []; - } - else { - var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : []; - filters = [this.constructFilter(selected, unselected)]; - } - } - - return filters; - }, - - // Implement interface LABKEY.FilterDialog.ViewPanel - setFilters : function(filterArray) { - if (Ext.isArray(filterArray)) { - this.filters = filterArray; - this.onViewReady(); - } - }, - - getGridConfig : function(idx) { - var sm = new Ext.grid.CheckboxSelectionModel({ - listeners: { - selectionchange: { - fn: function(sm) { - // NOTE: this will manually set the checked state of the header checkbox. it would be better - // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3 - // and ext4 will be quite different it doesnt seem worth the effort right now - var selections = sm.getSelections(); - var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div'); - if(selections.length == sm.grid.store.getCount()){ - headerCell.addClass('x-grid3-hd-checker-on'); - } - else { - headerCell.removeClass('x-grid3-hd-checker-on'); - } - - - }, - buffer: 50 - } - } - }); - - var me = this; - - return { - xtype: 'grid', - id: this.gridID, - border: true, - bodyBorder: true, - frame: false, - autoHeight: true, - itemId: 'inputField' + (idx || 0), - filterIndex: idx || 0, - msgTarget: 'title', - store: this.getLookupStore(), - headerClick: false, - viewConfig: { - headerTpl: new Ext.Template( - '', - '', - '{cells}', - '', - '
' - ) - }, - sm: sm, - cls: 'x-grid-noborder', - columns: [ - sm, - new Ext.grid.TemplateColumn({ - header: '[All]', - dataIndex: 'value', - menuDisabled: true, - resizable: false, - width: 340, - tpl: new Ext.XTemplate('' + - '' + - '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' + - '') - }) - ], - listeners: { - afterrender : function(grid) { - grid.getSelectionModel().on('selectionchange', function() { - this.changed = true; - }, this); - - grid.on('viewready', function(g) { - this.gridReady = true; - this.onViewReady(); - }, this, {single: true}); - }, - scope : this - }, - // extend toggle behavior to the header cell, not just the checkbox next to it - onHeaderCellClick : function() { - var sm = this.getSelectionModel(); - var selected = sm.getSelections(); - selected.length == this.store.getCount() ? this.selectNone() : this.selectAll(); - }, - getValue : function() { - var vals = this.getValues(); - if (vals.length == vals.max) { - return []; - } - return vals.values; - }, - getValues : function() { - var values = [], - sels = this.getSelectionModel().getSelections(); - - Ext.each(sels, function(rec){ - values.push(rec.get('strValue')); - }, this); - - if(values.indexOf('') != -1 && values.length == 1) - values.push(''); //account for null-only filtering - - return { - values : values.join(';'), - length : values.length, - max : this.getStore().getCount() - }; - }, - setValue : function(values, negated) { - if (!this.rendered) { - this.on('render', function() { - this.setValue(values, negated); - }, this, {single: true}); - } - - if (!Ext.isArray(values)) { - values = values.split(';'); - } - - if (this.store.isLoading) { - // need to wait for the store to load to ensure records - this.store.on('load', function() { - this._checkAndLoadValues(values, negated); - }, this, {single: true}); - } - else { - this._checkAndLoadValues(values, negated); - } - }, - _checkAndLoadValues : function(values, negated) { - var records = [], - recIdx, - recordNotFound = false; - - Ext.each(values, function(val) { - recIdx = this.store.findBy(function(rec){ - return rec.get('strValue') === val; - }); - - if (recIdx != -1) { - records.push(recIdx); - } - else { - // Issue 14710: if the record isnt found, we wont be able to select it, so should reject. - // If it's null/empty, ignore silently - if (!Ext.isEmpty(val)) { - recordNotFound = true; - return false; - } - } - }, this); - - if (negated) { - var count = this.store.getCount(), found = false, negRecords = []; - for (var i=0; i < count; i++) { - found = false; - for (var j=0; j < records.length; j++) { - if (records[j] == i) - found = true; - } - if (!found) { - negRecords.push(i); - } - } - records = negRecords; - } - - if (recordNotFound) { - // cannot find any matching records - if (me.column.facetingBehaviorType != 'ALWAYS_ON') - me.fireEvent('invalidfacetedfilter'); - return; - } - - this.getSelectionModel().selectRows(records); - }, - selectAll : function() { - if (this.rendered) { - var sm = this.getSelectionModel(); - sm.selectAll.defer(10, sm); - } - else { - this.on('render', this.selectAll, this, {single: true}); - } - }, - selectNone : function() { - if (this.rendered) { - this.getSelectionModel().selectRows([]); - } - else { - this.on('render', this.selectNone, this, {single: true}); - } - }, - determineNegation: function(filter) { - var suffix = filter.getFilterType().getURLSuffix(); - var negated = suffix == 'neqornull' || suffix == 'notin'; - - // negation of the null case is a bit different so check it as a special case. - var value = filter.getURLParameterValue(); - if (value == "" && suffix != 'isblank') { - negated = true; - } - return negated; - }, - selectFilter : function(filter) { - var negated = this.determineNegation(filter); - - this.setValue(filter.getURLParameterValue(), negated); - - if (!me.filterOptimization && negated) { - me.fireEvent('invalidfacetedfilter'); - } - }, - scope : this - }; - }, - - shouldShowFaceted : function(filter) { - const CHOOSE_VALUE_FILTERS = [ - LABKEY.Filter.Types.EQUAL.getURLSuffix(), - LABKEY.Filter.Types.IN.getURLSuffix(), - LABKEY.Filter.Types.NEQ.getURLSuffix(), - LABKEY.Filter.Types.NEQ_OR_NULL.getURLSuffix(), - LABKEY.Filter.Types.NOT_IN.getURLSuffix(), - ]; - - if (!filter) - return true; - - return CHOOSE_VALUE_FILTERS.indexOf(filter.getFilterType().getURLSuffix()) >= 0; - }, - - onViewReady : function() { - if (this.gridReady && this.storeReady) { - var grid = Ext.getCmp(this.gridID); - this.hideMask(); - - if (grid) { - - var numFilters = this.filters.length; - var numFacets = grid.store.getCount(); - - // apply current filter - if (numFacets == 0) - grid.selectNone(); - else if (numFilters == 0) - grid.selectAll(); - else - grid.selectFilter(this.filters[0]); - - // Issue 52547: LKS filter dialog treats many filter types as if they are Equals - if (numFilters > 1 || !this.shouldShowFaceted(this.filters[0])) - this.fireEvent('invalidfacetedfilter'); - - if (!grid.headerClick) { - grid.headerClick = true; - var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div'); - div.on('click', grid.onHeaderCellClick, grid); - } - - if (this.loadError) { - var errorCmp = Ext.getCmp(this.gridID + '-error'); - errorCmp.update(this.loadError); - errorCmp.setVisible(true); - } - - // Issue 39727 - show a message if we've capped the number of options shown - Ext.getCmp(this.gridID + 'OverflowLabel').setVisible(this.overflow); - - if (this.loadError || this.overflow) { - this.fireEvent('invalidfacetedfilter'); - } - } - } - - this.changed = false; - }, - - getLookupStore : function() { - var dr = this.getDataRegion(); - var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id(); - - // cache - var store = Ext.StoreMgr.get(storeId); - if (store) { - this.storeReady = true; // unsafe - return store; - } - - store = new Ext.data.ArrayStore({ - fields : ['value', 'strValue', 'displayValue'], - storeId: storeId - }); - - var config = { - schemaName: dr.schemaName, - queryName: dr.queryName, - dataRegionName: dr.name, - viewName: dr.viewName, - column: this.fieldKey, - filterArray: dr.filters, - containerPath: dr.container || dr.containerPath || LABKEY.container.path, - containerFilter: dr.getContainerFilter(), - parameters: dr.getParameters(), - maxRows: this.MAX_FILTER_CHOICES+1, - ignoreFilter: dr.ignoreFilter, - success : function(d) { - if (d && d.values) { - var recs = [], v, i=0, hasBlank = false, isString, formattedValue; - - // Issue 39727 - remember if we exceeded our cap so we can show a message - this.overflow = d.values.length > this.MAX_FILTER_CHOICES; - - for (; i < Math.min(d.values.length, this.MAX_FILTER_CHOICES); i++) { - v = d.values[i]; - formattedValue = this.formatValue(v); - isString = Ext.isString(formattedValue); - - if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) { - hasBlank = true; - } - else if (Ext.isDefined(v)) { - recs.push([v, v.toString(), v.toString()]); - } - } - - if (hasBlank) - recs.unshift(['', '', this.emptyDisplayValue]); - - store.loadData(recs); - store.isLoading = false; - this.storeReady = true; - this.onViewReady(); - } - }, - failure: function(err) { - if (err && err.exception) { - this.loadError = err.exception; - } else { - this.loadError = 'Failed to load faceted data.'; - } - store.isLoading = false; - this.storeReady = true; - this.onViewReady(); - }, - scope: this - }; - - if (this.applyContextFilters) { - var userFilters = dr.getUserFilterArray(); - if (userFilters && userFilters.length > 0) { - - var uf = []; - - // Remove filters for the current column - for (var i=0; i < userFilters.length; i++) { - if (userFilters[i].getColumnName() != this.fieldKey) { - uf.push(userFilters[i]); - } - } - - config.filterArray = uf; - } - } - - // Use Select Distinct - LABKEY.Query.selectDistinctRows(config); - - return Ext.StoreMgr.add(store); - }, - - onPanelRender : function(panel) { - var toAdd = [{ - xtype: 'panel', - width: this.width - 40, //prevent horizontal scroll - bodyStyle: 'padding-left: 5px;', - items: [ this.getGridConfig(0) ], - listeners : { - afterrender : { - fn: this.showMask, - scope: this, - single: true - } - } - }]; - panel.add(toAdd); - }, - - showMask : function() { - if (!this.gridReady && this.getEl()) { - this.getEl().mask('Loading...'); - } - }, - - hideMask : function() { - if (this.getEl()) { - this.getEl().unmask(); - } - } -}); - -Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted); - -LABKEY.FilterDialog.View.ConceptFilter = Ext.extend(LABKEY.FilterDialog.View.Default, { - - initComponent: function () { - this.updateConceptFilters = []; - - LABKEY.FilterDialog.View.ConceptFilter.superclass.initComponent.call(this); - }, - - getListenerConfig: function(index) { - if (!this.updateConceptFilters[index]) { - this.updateConceptFilters[index] = {filterIndex: index}; - } - - return this.updateConceptFilters[index]; - }, - - //Callback from RequireScripts is passed a contextual this object - loadConceptPickers: function() { - const ctx = this; - const divId = ctx.divId, - index = ctx.index, - scope = ctx.scope; - - LABKEY.App.loadApp('conceptFilter', divId, { - ontologyId: scope.column.sourceOntology, - conceptSubtree: scope.column.conceptSubtree, - columnName: scope.column.caption, - onFilterChange: function(filterValue) { - // Inputs may be set after app load, so look it up at execution time - const inputs = scope.inputs; - if (!inputs) - return; - - const textInput = inputs[index * 2]; // one text input, one textarea input - const textAreaInput = inputs[index * 2 + 1]; - const targetInput = textInput && !textInput.hidden ? textInput: textAreaInput; - - // push values selected in tree to the target input control - if (targetInput && !targetInput.disabled) { - targetInput.setValue(filterValue); - targetInput.validate(); - } - }, - subscribeFilterValue: function(listener) { - scope.getListenerConfig(index).setValue = listener; - this.changed = true; - }, - unsubscribeFilterValue: function() { - scope.getListenerConfig(index).setValue = undefined; - }, - subscribeFilterTypeChanged: function(listener) { - scope.getListenerConfig(index).setFilterType = listener; - this.changed = true; - }, - unsubscribeFilterTypeChanged: function() { - scope.getListenerConfig(index).setFilterType = undefined; - }, - loadListener: function() { - scope.onViewReady(); // TODO be a little more targeted, but this ensures the filtertype & filterValue parameters get set because the Ext elements get rendered & set async - }, - subscribeCollapse: function(listener) { - scope.getListenerConfig(index).collapsePanel = listener; - }, - unsubscribeCollapse: function() { - scope.getListenerConfig(index).collapsePanel = undefined; - }, - onOpen: function() { - scope.updateConceptFilters.forEach( function(panel) { - if (panel.filterIndex !== index) panel.collapsePanel(); - }); - } - }); - }, - - addFilterConfig: function(idx, items) { - LABKEY.FilterDialog.View.ConceptFilter.superclass.addFilterConfig.call(this, idx, items); - - const divId = LABKEY.Utils.generateUUID(); - items.push( this.getConceptBrowser(idx, divId)); - }, - - getConceptBrowser: function (idx, divId) { - if (this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI) { - const index = idx; - return { - xtype: 'panel', - layout: 'form', - id: divId, - border: false, - defaults: this.itemDefaults, - items: [{ - value: 'a', - scope: this - }], - listeners: { - render: function() { - // const conceptFilterScript = 'http://localhost:3001/conceptFilter.js'; - const conceptFilterScript = 'gen/conceptFilter'; - LABKEY.requiresScript(conceptFilterScript, this.loadConceptPickers, {divId:divId, index:index, scope:this}); - }, - scope: this - }, - scope: this - }; - } - }, - - getDefaultFilterType: function(idx) { - //Override the default for Concepts unless it is blank - return idx === 0 ? LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE.getURLSuffix() : ''; - }, - - getFilterTypes: function() { - return [ - LABKEY.Filter.Types.HAS_ANY_VALUE, - LABKEY.Filter.Types.EQUAL, - LABKEY.Filter.Types.NEQ_OR_NULL, - LABKEY.Filter.Types.ISBLANK, - LABKEY.Filter.Types.NONBLANK, - LABKEY.Filter.Types.IN, - LABKEY.Filter.Types.NOT_IN, - LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE, - LABKEY.Filter.Types.ONTOLOGY_NOT_IN_SUBTREE - ]; - }, - - enableInputField: function(combo) { - LABKEY.FilterDialog.View.ConceptFilter.superclass.enableInputField.call(this, combo); - - const idx = combo.filterIndex; - const filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); - if (this.updateConceptFilters) { - const updater = this.updateConceptFilters[idx]; - if (updater) { - updater.setFilterType(filter); - } - } - }, - - inputListener : function(input, newVal, oldVal) { - const idx = input.filterIndex; - if (oldVal != newVal) { - this.changed = true; - - const updater = this.updateConceptFilters[idx]; - if (updater) { - updater.setValue(newVal); - } - } - }, - - updateViewReady: function(f) { - LABKEY.FilterDialog.View.ConceptFilter.superclass.updateViewReady.call(this, f); - - // Update concept filters if possible - if (this.updateConceptFilters[f]) { - const filter = this.filters[f]; - const conceptBrowserUpdater = this.updateConceptFilters[f]; - - conceptBrowserUpdater.setValue(filter.getURLParameterValue()); - conceptBrowserUpdater.setFilterType(filter.getFilterType()); - } - } -}); - -Ext.reg('filter-view-conceptfilter', LABKEY.FilterDialog.View.ConceptFilter); - -Ext.ns('LABKEY.ext'); - -LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, { - initComponent : function() { - Ext.apply(this, { - validator: function(val){ - if(!val) - return true; - - return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0."; - } - }); - LABKEY.ext.BooleanTextField.superclass.initComponent.call(this); - } -}); - -Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField); +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +const CONCEPT_CODE_CONCEPT_URI = 'http://www.labkey.org/types#conceptCode'; + +LABKEY.FilterDialog = Ext.extend(Ext.Window, { + + autoHeight: true, + + bbarCfg : { + bodyStyle : 'border-top: 1px solid black;' + }, + + cls: 'labkey-filter-dialog', + + closeAction: 'destroy', + + defaults: { + border: false, + msgTarget: 'under' + }, + + itemId: 'filterWindow', + + modal: true, + + resizable: false, + + // 24846 + width: Ext.isGecko ? 425 : 410, + + allowFacet : undefined, + + cacheFacetResults: true, + + hasOntologyModule: false, + + initComponent : function() { + + if (!this['dataRegionName']) { + console.error('dataRegionName is required for a LABKEY.FilterDialog'); + return; + } + + this.column = this.column || this.boundColumn; // backwards compat + if (!this.configureColumn(this.column)) { + return; + } + + this.hasOntologyModule = LABKEY.moduleContext.api.moduleNames.indexOf('ontology') > -1; + + Ext.apply(this, { + title: this.title || "Show Rows Where " + this.column.caption + "...", + + carryfilter : true, // whether filter state should try to be carried between views (e.g. when changing tabs) + + // buttons + buttons: this.configureButtons(), + + // hook key events + keys:[{ + key: Ext.EventObject.ENTER, + handler: this.onKeyEnter, + scope: this + },{ + key: Ext.EventObject.ESC, + handler: this.closeDialog, + scope: this + }], + width: this.isConceptColumnFilter() ? + (Ext.isGecko ? 613 : 598) : + // 24846 + (Ext.isGecko ? 505 : 490), + // listeners + listeners: { + destroy: function() { + if (this.focusTask) { + Ext.TaskMgr.stop(this.focusTask); + } + }, + resize : function(panel) { panel.syncShadow(); }, + scope : this + } + }); + + this.items = [this.getContainer()]; + + LABKEY.FilterDialog.superclass.initComponent.call(this); + }, + + allowFaceting : function() { + if (Ext.isDefined(this.allowFacet)) + return this.allowFacet; + + var dr = this.getDataRegion(); + if (!this.isQueryDataRegion(dr)) { + this.allowFacet = false; + return this.allowFacet; + } + + this.allowFacet = false; + if (this.column.inputType === 'file') + return this.allowFacet; + + switch (this.column.facetingBehaviorType) { + + case 'ALWAYS_ON': + this.allowFacet = true; + break; + case 'ALWAYS_OFF': + this.allowFacet = false; + break; + case 'AUTOMATIC': + // auto rules are if the column is a lookup or dimension + // OR if it is of type : (boolean, int, date, text), multiline excluded + if (this.column.lookup || this.column.dimension) + this.allowFacet = true; + else if (this.jsonType == 'boolean' || this.jsonType == 'int' || + (this.jsonType == 'string' && this.column.inputType != 'textarea')) + this.allowFacet = true; + break; + } + + return this.allowFacet; + }, + + // Returns an Array of button configurations based on supported operations on this column + configureButtons : function() { + var buttons = [ + {text: 'OK', handler: this.onApply, scope: this}, + {text: 'Cancel', handler: this.closeDialog, scope: this} + ]; + + if (this.getDataRegion()) { + buttons.push({text: 'Clear Filter', handler: this.clearFilter, scope: this}); + buttons.push({text: 'Clear All Filters', handler: this.clearAllFilters, scope: this}); + } + + return buttons; + }, + + // Returns true if the initialization was a success + configureColumn : function(column) { + if (!column) { + console.error('A column is required for LABKEY.FilterDialog'); + return false; + } + + Ext.apply(this, { + // DEPRECATED: Either invoked from GWT, which will handle the commit itself. + // Or invoked as part of a regular filter dialog on a grid + changeFilterCallback: this.confirmCallback, + + fieldCaption: column.caption, + fieldKey: column.lookup && column.displayField ? column.displayField : column.fieldKey, // terrible + jsonType: (column.displayFieldJsonType ? column.displayFieldJsonType : column.jsonType) || 'string' + }); + + return true; + }, + + onKeyEnter : function() { + var view = this.getContainer().getActiveTab(); + var filters = view.getFilters() + if (filters && filters.length > 0) { + var hasMultiValueFilter = false; + filters.forEach(filter => { + var urlSuffix = filter.getFilterType().getURLSuffix(); + if (filter.getFilterType().isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) + hasMultiValueFilter = true; + }) + if (hasMultiValueFilter) + return; + } + + + this.onApply(); + }, + + hasMultiValueFilter: function() { + this._getFilters() + }, + + onApply : function() { + if (this.apply()) + this.closeDialog(); + }, + + // Validates and applies the current filter(s) to the DataRegion + apply : function() { + var view = this.getContainer().getActiveTab(); + var isValid = true; + + if (!view.getForm().isValid()) + isValid = false; + + if (isValid) { + isValid = view.checkValid(); + } + + if (isValid) { + + var dr = this.getDataRegion(), + filters = view.getFilters(); + + if (Ext.isFunction(this.changeFilterCallback)) { + + var filterParams = '', sep = ''; + for (var f=0; f < filters.length; f++) { + filterParams += sep + encodeURIComponent(filters[f].getURLParameterName(this.dataRegionName)) + '=' + encodeURIComponent(filters[f].getURLParameterValue()); + sep = '&'; + } + this.changeFilterCallback.call(this, null, null, filterParams); + } + else { + if (filters.length > 0) { + // add the current filter(s) + if (view.supportsMultipleFilters) { + dr.replaceFilters(filters, this.column); + } + else + dr.replaceFilter(filters[0]); + } + else { + this.clearFilter(); + } + } + } + + return isValid; + }, + + clearFilter : function() { + var dr = this.getDataRegion(); + if (!dr) { return; } + Ext.StoreMgr.clear(); + dr.clearFilter(this.fieldKey); + this.closeDialog(); + }, + + clearAllFilters : function() { + var dr = this.getDataRegion(); + if (!dr) { return; } + dr.clearAllFilters(); + this.closeDialog(); + }, + + closeDialog : function() { + this.close(); + }, + + getDataRegion : function() { + return LABKEY.DataRegions[this.dataRegionName]; + }, + + isQueryDataRegion : function(dr) { + return dr && dr.schemaName && dr.queryName; + }, + + // Returns a class instance of a class that extends Ext.Container. + // This container will hold all the views registered to this FilterDialog instance. + // For caching purposes assign to this.viewcontainer + getContainer : function() { + + if (!this.viewcontainer) { + + var views = this.getViews(); + var type = 'TabPanel'; + + if (views.length == 1) { + views[0].title = false; + type = 'Panel'; + } + + var config = { + defaults: this.defaults, + deferredRender: false, + monitorValid: true, + + // sizing and styling + autoHeight: true, + bodyStyle: 'margin: 0 5px;', + border: true, + items: views + }; + + if (type == 'TabPanel') { + config.listeners = { + beforetabchange : function(tp, newTab, oldTab) { + if (this.carryfilter && newTab && oldTab && oldTab.isChanged()) { + newTab.setFilters(oldTab.getFilters()); + } + }, + tabchange : function() { + this.syncShadow(); + this.viewcontainer.getActiveTab().doLayout(); // required when facets return while on another tab + }, + scope : this + }; + } + + if (views.length > 1) { + config.activeTab = this.getDefaultTab(); + } + else { + views[0].title = false; + } + + this.viewcontainer = new Ext[type](config); + + if (!Ext.isFunction(this.viewcontainer.getActiveTab)) { + var me = this; + this.viewcontainer.getActiveTab = function() { + return me.viewcontainer.items.items[0]; + }; + // views attempt to hook the 'activate' event but some panel types do not fire + // force fire on the first view + this.viewcontainer.items.items[0].on('afterlayout', function(p) { + p.fireEvent('activate', p); + }, this, {single: true}); + } + } + + return this.viewcontainer; + }, + + _getFilters : function() { + var filters = []; + + var dr = this.getDataRegion(); + if (dr) { + Ext.each(dr.getUserFilterArray(), function(ff) { + if (this.column.lookup && this.column.displayField && ff.getColumnName().toLowerCase() === this.column.displayField.toLowerCase()) { + filters.push(ff); + } + else if (this.column.fieldKey && ff.getColumnName().toLowerCase() === this.column.fieldKey.toLowerCase()) { + filters.push(ff); + } + }, this); + } + else if (this.queryString) { // deprecated + filters = LABKEY.Filter.getFiltersFromUrl(this.queryString, this.dataRegionName); + } + + return filters; + }, + + getDefaultTab: function() { + return this.isConceptColumnFilter() ? + 0 : (this.allowFaceting() ? 1 : 0); + }, + + isConceptColumnFilter: function() { + return this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI && this.hasOntologyModule; + }, + + getDefaultView: function(filters) { + const xtypeVal = this.isConceptColumnFilter() + ? 'filter-view-conceptfilter' + : 'filter-view-default'; + + return { + xtype: xtypeVal, + column: this.column, + fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly + dataRegionName: this.dataRegionName, + jsonType : this.jsonType, + filters: filters + }; + }, + + // Override to return your own filter views + getViews : function() { + + const filters = this._getFilters(), views = []; + + // default view + views.push(this.getDefaultView(filters)); + + // facet view + if (this.allowFaceting()) { + views.push({ + xtype: 'filter-view-faceted', + column: this.column, + fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly + dataRegionName: this.dataRegionName, + jsonType : this.jsonType, + filters: filters, + cacheResults: this.cacheFacetResults, + listeners: { + invalidfacetedfilter : function() { + this.carryfilter = false; + this.getContainer().setActiveTab(0); + this.getContainer().getActiveTab().doLayout(); + this.carryfilter = true; + }, + scope: this + }, + scope: this + }) + } + + return views; + } +}); + +LABKEY.FilterDialog.ViewPanel = Ext.extend(Ext.form.FormPanel, { + + supportsMultipleFilters: false, + + filters : [], + + changed : false, + + initComponent : function() { + if (!this['dataRegionName']) { + console.error('dataRegionName is required for a LABKEY.FilterDialog.ViewPanel'); + return; + } + LABKEY.FilterDialog.ViewPanel.superclass.initComponent.call(this); + }, + + // Override to provide own view validation + checkValid : function() { + return true; + }, + + getDataRegion : function() { + return LABKEY.DataRegions[this.dataRegionName]; + }, + + getFilters : function() { + console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement getFilters()'); + }, + + setFilters : function(filterArray) { + console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement setFilters(filterArray)'); + }, + + getXtypes : function() { + const textInputTypes = ['textfield', 'textarea']; + switch (this.jsonType) { + case "date": + return ["datefield"]; + case "int": + case "float": + return textInputTypes; + case "boolean": + return ['labkey-booleantextfield']; + default: + return textInputTypes; + } + }, + + // Returns true if a view has been altered since the last time it was activated + isChanged : function() { + return this.changed; + } +}); + +Ext.ns('LABKEY.FilterDialog.View'); + +LABKEY.FilterDialog.View.Default = Ext.extend(LABKEY.FilterDialog.ViewPanel, { + + supportsMultipleFilters: true, + + itemDefaults: { + border: false, + msgTarget: 'under' + }, + + initComponent : function() { + + Ext.apply(this, { + autoHeight: true, + title: this.title === false ? false : 'Choose Filters', + bodyStyle: 'padding: 5px;', + bubbleEvents: ['add', 'remove', 'clientvalidation'], + defaults: { border: false }, + items: this.generateFilterDisplays(2) + }); + + this.combos = []; + this.inputs = []; + + LABKEY.FilterDialog.View.Default.superclass.initComponent.call(this); + + this.on('activate', this.onViewReady, this, {single: true}); + }, + + updateViewReady: function(f) { + var filter = this.filters[f]; + var combo = this.combos[f]; + + // Update the input enabled/disabled status by using the 'select' event listener on the combobox. + // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. + var store = combo.getStore(); + var filterType = filter.getFilterType(); + var urlSuffix = filterType.getURLSuffix(); + if (store) { + var rec = store.getAt(store.find('value', urlSuffix)); + if (rec) { + combo.setValue(urlSuffix); + combo.fireEvent('select', combo, rec); + } + } + + var inputValue = filter.getURLParameterValue(); + + if (this.jsonType === "date" && inputValue) { + const dateVal = Date.parseDate(inputValue, LABKEY.extDateInputFormat); // date inputs are formatted to ISO date format on server + inputValue = dateVal.format(LABKEY.extDefaultDateFormat); // convert back to date field accepted format for render + } + + // replace multivalued separator (i.e. ;) with \n on UI + if (filterType.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { + var valueSeparator = filterType.getMultiValueSeparator(); + if (typeof inputValue === 'string' && inputValue.indexOf('\n') === -1 && inputValue.indexOf(valueSeparator) > 0) { + inputValue = filterType.parseValue(inputValue); + if (LABKEY.Utils.isArray(inputValue)) + inputValue = inputValue.join('\n'); + } + } + + var inputs = this.getVisibleInputs(); + if (inputs[f]) { + inputs[f].setValue(inputValue); + } + }, + + onViewReady : function() { + var inputs = this.getVisibleInputs(); + if (this.filters.length == 0) { + for (var c=0; c < this.combos.length; c++) { + // Update the input enabled/disabled status by using the 'select' event listener on the combobox. + // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. + this.combos[c].reset(); + this.combos[c].fireEvent('select', this.combos[c], null); + if (inputs[c]) { + inputs[c].reset(); + } + } + } + else { + for (var f=0; f < this.filters.length; f++) { + if (f < this.combos.length) { + this.updateViewReady(f); + } + } + } + + //Issue 24550: always select the first filter field, and also select text if present + if (inputs[0]) { + inputs[0].focus(true, 100, inputs[0]); + } + + this.changed = false; + }, + + getVisibleInputs: function() { + return this.inputs.filter(input => !input.hidden); + }, + + checkValid : function() { + var combos = this.combos; + var inputs = this.getVisibleInputs(), input, value, f; + + var isValid = true; + + Ext.each(combos, function(c, i) { + if (!c.isValid()) { + isValid = false; + } + else { + input = inputs[i]; + value = input.getValue(); + + f = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); + + if (!f) { + alert('filter not found: ' + c.getValue()); + return; + } + + if (f.isDataValueRequired() && Ext.isEmpty(value)) { + input.markInvalid('You must enter a value'); + isValid = false; + } + } + }); + + return isValid; + }, + + inputFieldValidator : function(input, combo) { + + var store = combo.getStore(); + if (store) { + var rec = store.getAt(store.find('value', combo.getValue())); + var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); + + if (rec) { + if (filter.isMultiValued()) + return this.validateMultiValueInput(input.getValue(), filter.getMultiValueSeparator(), filter.getMultiValueMinOccurs(), filter.getMultiValueMaxOccurs()); + return this.validateInputField(input.getValue()); + } + } + return true; + }, + + addFilterConfig: function(idx, items) { + var subItems = [this.getComboConfig(idx)]; + var inputConfigs = this.getInputConfigs(idx); + inputConfigs.forEach(config => { + subItems.push(config); + }); + items.push({ + xtype: 'panel', + layout: 'form', + itemId: 'filterPair' + idx, + border: false, + defaults: this.itemDefaults, + items: subItems, + scope: this + }); + }, + + generateFilterDisplays : function(quantity) { + var idx = this.nextIndex(), items = [], i=0; + + for(; i < quantity; i++) { + this.addFilterConfig(idx, items); + + idx++; + } + + return items; + }, + + getDefaultFilterType: function(idx) { + return idx === 0 ? LABKEY.Filter.getDefaultFilterForType(this.jsonType).getURLSuffix() : ''; + }, + + getComboConfig : function(idx) { + var val = this.getDefaultFilterType(idx); + + return { + xtype: 'combo', + itemId: 'filterComboBox' + idx, + filterIndex: idx, + name: 'filterType_'+(idx + 1), //for compatibility with tests... + listWidth: (this.jsonType == 'date' || this.jsonType == 'boolean') ? null : 380, + emptyText: idx === 0 ? 'Choose a filter:' : 'No other filter', + autoSelect: false, + width: 330, + minListWidth: 330, + triggerAction: 'all', + fieldLabel: (idx === 0 ?'Filter Type' : 'and'), + store: this.getSelectionStore(idx), + displayField: 'text', + valueField: 'value', + typeAhead: 'false', + forceSelection: true, + mode: 'local', + clearFilterOnReset: false, + editable: false, + value: val, + originalValue: val, + listeners : { + render : function(combo) { + this.combos.push(combo); + // Update the associated inputField's enabled/disabled state on initial render + this.enableInputField(combo); + }, + select : function (combo) { + this.changed = true; + this.enableInputField(combo); + }, + scope: this + }, + scope: this + }; + }, + + enableInputField : function (combo) { + + var idx = combo.filterIndex; + var inputField = this.find('itemId', 'inputField'+idx+'-0')[0]; + var textAreaField = this.find('itemId', 'inputField'+idx+'-1')[0]; + + const urlSuffix = combo.getValue().toLowerCase(); + var filter = LABKEY.Filter.getFilterTypeForURLSuffix(urlSuffix); + var selectedValue = filter ? filter.getURLSuffix() : ''; + + var combos = this.combos; + var inputFields = this.inputs; + + if (filter && !filter.isDataValueRequired()) { + //Disable the field and allow it to be blank for values 'isblank' and 'isnonblank'. + inputField.disable(); + inputField.setValue(); + inputField.blur(); + if (textAreaField) + { + textAreaField.disable(); + textAreaField.setValue(); + textAreaField.blur(); + } + } + else { + if (filter.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { + textAreaField.show(); + textAreaField.enable(); + textAreaField.setValue(inputField.getValue()); + textAreaField.validate(); + textAreaField.focus('', 50); + inputField.hide(); + } + else { + inputField.show(); + inputField.enable(); + inputField.setValue(textAreaField && textAreaField.getValue()); + inputField.validate(); + inputField.focus('', 50); + textAreaField && textAreaField.hide(); + } + } + + //if the value is null, this indicates no filter chosen. if it lacks an operator (ie. isBlank) + //in either case, this means we should disable all other filters + if(selectedValue == '' || !filter.isDataValueRequired()){ + //Disable all subsequent combos + Ext.each(combos, function(combo, idx) { + //we enable the next combo in the series + if(combo.filterIndex == this.filterIndex + 1){ + combo.setValue(); + inputFields[idx].setValue(); + inputFields[idx].enable(); + inputFields[idx].validate(); + inputFields[idx].blur(); + } + else if (combo.filterIndex > this.filterIndex){ + combo.setValue(); + inputFields[idx].disable(); + } + + }, this); + } + else{ + //enable the other filterComboBoxes. + Ext.each(combos, function(combo, i) { combo.enable(); }, this); + + if (combos.length) { + combos[0].focus('', 50); + } + } + }, + + getFilters : function() { + + var inputs = this.getVisibleInputs(); + var combos = this.combos; + var value, type, filters = []; + + Ext.each(combos, function(c, i) { + if (!inputs[i].disabled || (c.getRawValue() != 'No Other Filter')) { + value = inputs[i].getValue(); + type = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); + + if (!type) { + alert('Filter not found for suffix: ' + c.getValue()); + } + + // Issue 52068: for multivalued filter types, split on new line to get an array of values + if (value && type.isMultiValued()) { + value = value.indexOf('\n') > -1 ? value.split('\n') : type.parseValue(value); + } + + filters.push(LABKEY.Filter.create(this.fieldKey, value, type)); + } + }, this); + + return filters; + }, + + getAltDateFormats: function() { + if (this.jsonType === "date") + return 'Y-m-d|' + LABKEY.Utils.getDateAltFormats(); // always support ISO + return undefined; + }, + + getInputConfigs : function(idx) { + var me = this; + const xTypes = this.getXtypes(); + var configs = []; + xTypes.forEach((xType, typeId) => { + var config = { + xtype : xType, + itemId : 'inputField' + idx + '-' + typeId, + filterIndex : idx, + id : 'value_'+(idx + 1) + (typeId ? '-' + typeId: ''), //for compatibility with tests... + width : 330, + blankText : 'You must enter a value.', + validateOnBlur: true, + value : null, + altFormats : this.getAltDateFormats(), + hidden: typeId === 1, + disabled: typeId === 1, + emptyText: xType === 'textarea' ? 'Use new line or semicolon to separate entries' : (me.jsonType === 'time' ? 'HH:mm:ss' : undefined), + style: { resize: 'none' }, + validator : function(value) { + + // support for filtering '∞' + if (me.jsonType == 'float' && value.indexOf('∞') > -1) { + value = value.replace('∞', 'Infinity'); + this.setRawValue(value); // does not fire validation + } + + var combos = me.combos; + if (!combos.length) { + return; + } + + return me.inputFieldValidator(this, combos[idx]); + }, + listeners: { + disable : function(field){ + //Call validate after disable so any pre-existing validation errors go away. + if(field.rendered) { + field.validate(); + } + }, + focus : function(f) { + if (this.focusTask) { + Ext.TaskMgr.stop(this.focusTask); + } + }, + render : function(input) { + me.inputs.push(input); + if (!me.focusReady) { + me.focusReady = true; + // create a task to set the input focus that will get started after layout is complete, + // the task will run for a max of 2000ms but will get stopped when the component receives focus + this.focusTask = {interval:150, run: function(){ + input.focus(null, 50); + Ext.TaskMgr.stop(this.focusTask); + }, scope: this, duration: 2000}; + } + }, + change : this.inputListener, + scope : this + }, + scope: this + }; + if (this.jsonType === "date") { + config.format = LABKEY.extDefaultDateFormat; + + // default invalidText : "{0} is not a valid date - it must be in the format {1}", + // override the default warning msg as there is one preferred format, but there are also a set of acceptable altFormats + config.invalidText = "{0} might not be a valid date - the preferred format is {1}"; + } + + configs.push(config); + }) + return configs; + }, + + inputListener : function(input, newVal, oldVal) { + if (oldVal != newVal) { + this.changed = true; + } + }, + + getFilterTypes: function() { + return LABKEY.Filter.getFilterTypesForType(this.jsonType, this.column.mvEnabled); + }, + + getSelectionStore : function(storeNum) { + var fields = ['text', 'value', + {name: 'isMulti', type: Ext.data.Types.BOOL}, + {name: 'isOperatorOnly', type: Ext.data.Types.BOOL} + ]; + var store = new Ext.data.ArrayStore({ + fields: fields, + idIndex: 1 + }); + var comboRecord = Ext.data.Record.create(fields); + + var filters = this.getFilterTypes(); + + for (var i=0; i 0) { + store.removeAt(0); + store.insert(0, new comboRecord({text:'No Other Filter', value: ''})); + } + + return store; + }, + + setFilters : function(filterArray) { + this.filters = filterArray; + this.onViewReady(); + }, + + nextIndex : function() { + return 0; + }, + + validateMultiValueInput : function(inputValues, multiValueSeparator, minOccurs, maxOccurs) { + if (!inputValues) + return true; + // Used when "Equals One Of.." or "Between" is selected. Calls validateInputField on each value entered. + const sep = inputValues.indexOf('\n') > 0 ? '\n' : multiValueSeparator; + var values = inputValues.split(sep); + var isValid = ""; + for(var i = 0; i < values.length; i++){ + isValid = this.validateInputField(values[i]); + if(isValid !== true){ + return isValid; + } + } + + if (minOccurs !== undefined && minOccurs > 0) + { + if (values.length < minOccurs) + return "At least " + minOccurs + " '" + multiValueSeparator + "' separated values are required"; + } + + if (maxOccurs !== undefined && maxOccurs > 0) + { + if (values.length > maxOccurs) + return "At most " + maxOccurs + " '" + multiValueSeparator + "' separated values are allowed"; + } + + if (!Ext.isEmpty(inputValues) && typeof inputValues === 'string' && inputValues.trim().length > 2000) + return "Value is too long"; + + //If we make it out of the for loop we had no errors. + return true; + }, + + // The fact that Ext3 ties validation to the editor is a little funny, + // but using this shifts the work to Ext + validateInputField : function(value) { + var map = { + 'string': 'STRING', + 'time': 'STRING', + 'int': 'INT', + 'float': 'FLOAT', + 'date': 'DATE', + 'boolean': 'BOOL' + }; + var type = map[this.jsonType]; + if (type) { + var field = new Ext.data.Field({ + type: Ext.data.Types[type], + allowDecimals : this.jsonType != "int", //will be ignored by anything besides numberfield + useNull: true + }); + + var values = (!Ext.isEmpty(value) && typeof value === 'string' && value.indexOf('\n') > -1) ? value.split('\n') : [value]; + var invalid = null; + values.forEach(val => { + if (val == null) + return; + var convertedVal = field.convert(val); + if (!Ext.isEmpty(val) && val != convertedVal) { + invalid = val; + } + }) + + if (invalid != null) + return "Invalid value: " + invalid; + + if (!Ext.isEmpty(value) && typeof value === 'string' && value.trim().length > 2000) + return "Value is too long"; + } + else { + if (this.jsonType.toLowerCase() !== 'array') + console.log('Unrecognized type: ' + this.jsonType); + } + + return true; + } +}); + +Ext.reg('filter-view-default', LABKEY.FilterDialog.View.Default); + +LABKEY.FilterDialog.View.Faceted = Ext.extend(LABKEY.FilterDialog.ViewPanel, { + + MAX_FILTER_CHOICES: 250, // This is the maximum number of filters that will be requested / shown + + applyContextFilters: true, + + /** + * Logically convert filters to try and optimize the query on the server. + * (e.g. using NOT IN when less than half the available values are checked) + */ + filterOptimization: true, + + cacheResults: true, + + emptyDisplayValue: '[Blank]', + + gridID: Ext.id(), + + loadError: undefined, + + overflow: false, + + initComponent : function() { + + Ext.apply(this, { + title : 'Choose Values', + border : false, + height : 200, + bodyStyle: 'overflow-x: hidden; overflow-y: auto', + bubbleEvents: ['add', 'remove', 'clientvalidation'], + defaults : { + border : false + }, + markDisabled : true, + items: [{ + layout: 'hbox', + style: 'padding-bottom: 5px; overflow-x: hidden', + defaults: { + border: false + }, + items: [{ + xtype: 'box', + cls: 'alert alert-danger', + hidden: true, + id: this.gridID + '-error', + style: 'position: relative;', + },{ + xtype: 'label', + id: this.gridID + 'OverflowLabel', + hidden: true, + text: 'There are more than ' + this.MAX_FILTER_CHOICES + ' values. Showing a partial list.' + }] + }] + }); + + LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this); + + this.on('render', this.onPanelRender, this, {single: true}); + }, + + formatValue : function(val) { + if(this.column) { + if (this.column.extFormatFn) { + try { + this.column.extFormatFn = eval(this.column.extFormatFn); + } + catch (error) { + console.log('improper extFormatFn: ' + this.column.extFormatFn); + } + + if (Ext.isFunction(this.column.extFormatFn)) { + val = this.column.extFormatFn(val); + } + } + else if (this.jsonType == 'int') { + val = parseInt(val); + } + } + return val; + }, + + // copied from Ext 4 Ext.Array.difference + difference : function(arrayA, arrayB) { + var clone = arrayA.slice(), + ln = clone.length, + i, j, lnB; + + for (i = 0,lnB = arrayB.length; i < lnB; i++) { + for (j = 0; j < ln; j++) { + if (clone[j] === arrayB[i]) { + clone.splice(j, 1); + j--; + ln--; + } + } + } + + return clone; + }, + + constructFilter : function(selected, unselected) { + var filter = null; + + if (selected.length > 0) { + + var columnName = this.fieldKey; + + // one selection + if (selected.length == 1) { + if (selected[0].get('displayValue') == this.emptyDisplayValue) + filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK); + else + filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL + } + else if (this.filterOptimization && selected.length > unselected.length) { + // Do the negation + if (unselected.length == 1) { + var val = unselected[0].get('value'); + var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING); + + // 18716: Check if 'unselected' contains empty value + filter = LABKEY.Filter.create(columnName, val, type); + } + else + filter = LABKEY.Filter.create(columnName, this.selectedToValues(unselected), LABKEY.Filter.Types.NOT_IN); + } + else { + filter = LABKEY.Filter.create(columnName, this.selectedToValues(selected), LABKEY.Filter.Types.IN); + } + } + + return filter; + }, + + // get array of values from the selected store item array + selectedToValues : function(valueArray) { + return valueArray.map(function (i) { return i.get('value'); }); + }, + + // Implement interface LABKEY.FilterDialog.ViewPanel + getFilters : function() { + var grid = Ext.getCmp(this.gridID); + var filters = []; + + if (grid) { + var store = grid.store; + var count = store.getCount(); // TODO: Check if store loaded + var selected = grid.getSelectionModel().getSelections(); + + if (count == 0 || selected.length == 0 || selected.length == count) { + filters = []; + } + else { + var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : []; + filters = [this.constructFilter(selected, unselected)]; + } + } + + return filters; + }, + + // Implement interface LABKEY.FilterDialog.ViewPanel + setFilters : function(filterArray) { + if (Ext.isArray(filterArray)) { + this.filters = filterArray; + this.onViewReady(); + } + }, + + getGridConfig : function(idx) { + var sm = new Ext.grid.CheckboxSelectionModel({ + listeners: { + selectionchange: { + fn: function(sm) { + // NOTE: this will manually set the checked state of the header checkbox. it would be better + // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3 + // and ext4 will be quite different it doesnt seem worth the effort right now + var selections = sm.getSelections(); + var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div'); + if(selections.length == sm.grid.store.getCount()){ + headerCell.addClass('x-grid3-hd-checker-on'); + } + else { + headerCell.removeClass('x-grid3-hd-checker-on'); + } + + + }, + buffer: 50 + } + } + }); + + var me = this; + + return { + xtype: 'grid', + id: this.gridID, + border: true, + bodyBorder: true, + frame: false, + autoHeight: true, + itemId: 'inputField' + (idx || 0), + filterIndex: idx || 0, + msgTarget: 'title', + store: this.getLookupStore(), + headerClick: false, + viewConfig: { + headerTpl: new Ext.Template( + '', + '', + '{cells}', + '', + '
' + ) + }, + sm: sm, + cls: 'x-grid-noborder', + columns: [ + sm, + new Ext.grid.TemplateColumn({ + header: '[All]', + dataIndex: 'value', + menuDisabled: true, + resizable: false, + width: 340, + tpl: new Ext.XTemplate('' + + '' + + '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' + + '') + }) + ], + listeners: { + afterrender : function(grid) { + grid.getSelectionModel().on('selectionchange', function() { + this.changed = true; + }, this); + + grid.on('viewready', function(g) { + this.gridReady = true; + this.onViewReady(); + }, this, {single: true}); + }, + scope : this + }, + // extend toggle behavior to the header cell, not just the checkbox next to it + onHeaderCellClick : function() { + var sm = this.getSelectionModel(); + var selected = sm.getSelections(); + selected.length == this.store.getCount() ? this.selectNone() : this.selectAll(); + }, + getValue : function() { + var vals = this.getValues(); + if (vals.length == vals.max) { + return []; + } + return vals.values; + }, + getValues : function() { + var values = [], + sels = this.getSelectionModel().getSelections(); + + Ext.each(sels, function(rec){ + values.push(rec.get('strValue')); + }, this); + + if(values.indexOf('') != -1 && values.length == 1) + values.push(''); //account for null-only filtering + + return { + values : values.join(';'), + length : values.length, + max : this.getStore().getCount() + }; + }, + setValue : function(values, negated) { + if (!this.rendered) { + this.on('render', function() { + this.setValue(values, negated); + }, this, {single: true}); + } + + if (!Ext.isArray(values)) { + values = values.split(';'); + } + + if (this.store.isLoading) { + // need to wait for the store to load to ensure records + this.store.on('load', function() { + this._checkAndLoadValues(values, negated); + }, this, {single: true}); + } + else { + this._checkAndLoadValues(values, negated); + } + }, + _checkAndLoadValues : function(values, negated) { + var records = [], + recIdx, + recordNotFound = false; + + Ext.each(values, function(val) { + recIdx = this.store.findBy(function(rec){ + return rec.get('strValue') === val; + }); + + if (recIdx != -1) { + records.push(recIdx); + } + else { + // Issue 14710: if the record isnt found, we wont be able to select it, so should reject. + // If it's null/empty, ignore silently + if (!Ext.isEmpty(val)) { + recordNotFound = true; + return false; + } + } + }, this); + + if (negated) { + var count = this.store.getCount(), found = false, negRecords = []; + for (var i=0; i < count; i++) { + found = false; + for (var j=0; j < records.length; j++) { + if (records[j] == i) + found = true; + } + if (!found) { + negRecords.push(i); + } + } + records = negRecords; + } + + if (recordNotFound) { + // cannot find any matching records + if (me.column.facetingBehaviorType != 'ALWAYS_ON') + me.fireEvent('invalidfacetedfilter'); + return; + } + + this.getSelectionModel().selectRows(records); + }, + selectAll : function() { + if (this.rendered) { + var sm = this.getSelectionModel(); + sm.selectAll.defer(10, sm); + } + else { + this.on('render', this.selectAll, this, {single: true}); + } + }, + selectNone : function() { + if (this.rendered) { + this.getSelectionModel().selectRows([]); + } + else { + this.on('render', this.selectNone, this, {single: true}); + } + }, + determineNegation: function(filter) { + var suffix = filter.getFilterType().getURLSuffix(); + var negated = suffix == 'neqornull' || suffix == 'notin'; + + // negation of the null case is a bit different so check it as a special case. + var value = filter.getURLParameterValue(); + if (value == "" && suffix != 'isblank') { + negated = true; + } + return negated; + }, + selectFilter : function(filter) { + var negated = this.determineNegation(filter); + + this.setValue(filter.getURLParameterValue(), negated); + + if (!me.filterOptimization && negated) { + me.fireEvent('invalidfacetedfilter'); + } + }, + scope : this + }; + }, + + shouldShowFaceted : function(filter) { + const CHOOSE_VALUE_FILTERS = [ + LABKEY.Filter.Types.EQUAL.getURLSuffix(), + LABKEY.Filter.Types.IN.getURLSuffix(), + LABKEY.Filter.Types.NEQ.getURLSuffix(), + LABKEY.Filter.Types.NEQ_OR_NULL.getURLSuffix(), + LABKEY.Filter.Types.NOT_IN.getURLSuffix(), + ]; + + if (!filter) + return true; + + return CHOOSE_VALUE_FILTERS.indexOf(filter.getFilterType().getURLSuffix()) >= 0; + }, + + onViewReady : function() { + if (this.gridReady && this.storeReady) { + var grid = Ext.getCmp(this.gridID); + this.hideMask(); + + if (grid) { + + var numFilters = this.filters.length; + var numFacets = grid.store.getCount(); + + // apply current filter + if (numFacets == 0) + grid.selectNone(); + else if (numFilters == 0) + grid.selectAll(); + else + grid.selectFilter(this.filters[0]); + + // Issue 52547: LKS filter dialog treats many filter types as if they are Equals + if (numFilters > 1 || !this.shouldShowFaceted(this.filters[0])) + this.fireEvent('invalidfacetedfilter'); + + if (!grid.headerClick) { + grid.headerClick = true; + var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div'); + div.on('click', grid.onHeaderCellClick, grid); + } + + if (this.loadError) { + var errorCmp = Ext.getCmp(this.gridID + '-error'); + errorCmp.update(this.loadError); + errorCmp.setVisible(true); + } + + // Issue 39727 - show a message if we've capped the number of options shown + Ext.getCmp(this.gridID + 'OverflowLabel').setVisible(this.overflow); + + if (this.loadError || this.overflow) { + this.fireEvent('invalidfacetedfilter'); + } + } + } + + this.changed = false; + }, + + getLookupStore : function() { + var dr = this.getDataRegion(); + var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id(); + + // cache + var store = Ext.StoreMgr.get(storeId); + if (store) { + this.storeReady = true; // unsafe + return store; + } + + store = new Ext.data.ArrayStore({ + fields : ['value', 'strValue', 'displayValue'], + storeId: storeId + }); + + var config = { + schemaName: dr.schemaName, + queryName: dr.queryName, + dataRegionName: dr.name, + viewName: dr.viewName, + column: this.fieldKey, + filterArray: dr.filters, + containerPath: dr.container || dr.containerPath || LABKEY.container.path, + containerFilter: dr.getContainerFilter(), + parameters: dr.getParameters(), + maxRows: this.MAX_FILTER_CHOICES+1, + ignoreFilter: dr.ignoreFilter, + success : function(d) { + if (d && d.values) { + var recs = [], v, i=0, hasBlank = false, isString, formattedValue; + + // Issue 39727 - remember if we exceeded our cap so we can show a message + this.overflow = d.values.length > this.MAX_FILTER_CHOICES; + + for (; i < Math.min(d.values.length, this.MAX_FILTER_CHOICES); i++) { + v = d.values[i]; + formattedValue = this.formatValue(v); + isString = Ext.isString(formattedValue); + + if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) { + hasBlank = true; + } + else if (Ext.isDefined(v)) { + recs.push([v, v.toString(), v.toString()]); + } + } + + if (hasBlank) + recs.unshift(['', '', this.emptyDisplayValue]); + + store.loadData(recs); + store.isLoading = false; + this.storeReady = true; + this.onViewReady(); + } + }, + failure: function(err) { + if (err && err.exception) { + this.loadError = err.exception; + } else { + this.loadError = 'Failed to load faceted data.'; + } + store.isLoading = false; + this.storeReady = true; + this.onViewReady(); + }, + scope: this + }; + + if (this.applyContextFilters) { + var userFilters = dr.getUserFilterArray(); + if (userFilters && userFilters.length > 0) { + + var uf = []; + + // Remove filters for the current column + for (var i=0; i < userFilters.length; i++) { + if (userFilters[i].getColumnName() != this.fieldKey) { + uf.push(userFilters[i]); + } + } + + config.filterArray = uf; + } + } + + // Use Select Distinct + LABKEY.Query.selectDistinctRows(config); + + return Ext.StoreMgr.add(store); + }, + + onPanelRender : function(panel) { + var toAdd = [{ + xtype: 'panel', + width: this.width - 40, //prevent horizontal scroll + bodyStyle: 'padding-left: 5px;', + items: [ this.getGridConfig(0) ], + listeners : { + afterrender : { + fn: this.showMask, + scope: this, + single: true + } + } + }]; + panel.add(toAdd); + }, + + showMask : function() { + if (!this.gridReady && this.getEl()) { + this.getEl().mask('Loading...'); + } + }, + + hideMask : function() { + if (this.getEl()) { + this.getEl().unmask(); + } + } +}); + +Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted); + +LABKEY.FilterDialog.View.ConceptFilter = Ext.extend(LABKEY.FilterDialog.View.Default, { + + initComponent: function () { + this.updateConceptFilters = []; + + LABKEY.FilterDialog.View.ConceptFilter.superclass.initComponent.call(this); + }, + + getListenerConfig: function(index) { + if (!this.updateConceptFilters[index]) { + this.updateConceptFilters[index] = {filterIndex: index}; + } + + return this.updateConceptFilters[index]; + }, + + //Callback from RequireScripts is passed a contextual this object + loadConceptPickers: function() { + const ctx = this; + const divId = ctx.divId, + index = ctx.index, + scope = ctx.scope; + + LABKEY.App.loadApp('conceptFilter', divId, { + ontologyId: scope.column.sourceOntology, + conceptSubtree: scope.column.conceptSubtree, + columnName: scope.column.caption, + onFilterChange: function(filterValue) { + // Inputs may be set after app load, so look it up at execution time + const inputs = scope.inputs; + if (!inputs) + return; + + const textInput = inputs[index * 2]; // one text input, one textarea input + const textAreaInput = inputs[index * 2 + 1]; + const targetInput = textInput && !textInput.hidden ? textInput: textAreaInput; + + // push values selected in tree to the target input control + if (targetInput && !targetInput.disabled) { + targetInput.setValue(filterValue); + targetInput.validate(); + } + }, + subscribeFilterValue: function(listener) { + scope.getListenerConfig(index).setValue = listener; + this.changed = true; + }, + unsubscribeFilterValue: function() { + scope.getListenerConfig(index).setValue = undefined; + }, + subscribeFilterTypeChanged: function(listener) { + scope.getListenerConfig(index).setFilterType = listener; + this.changed = true; + }, + unsubscribeFilterTypeChanged: function() { + scope.getListenerConfig(index).setFilterType = undefined; + }, + loadListener: function() { + scope.onViewReady(); // TODO be a little more targeted, but this ensures the filtertype & filterValue parameters get set because the Ext elements get rendered & set async + }, + subscribeCollapse: function(listener) { + scope.getListenerConfig(index).collapsePanel = listener; + }, + unsubscribeCollapse: function() { + scope.getListenerConfig(index).collapsePanel = undefined; + }, + onOpen: function() { + scope.updateConceptFilters.forEach( function(panel) { + if (panel.filterIndex !== index) panel.collapsePanel(); + }); + } + }); + }, + + addFilterConfig: function(idx, items) { + LABKEY.FilterDialog.View.ConceptFilter.superclass.addFilterConfig.call(this, idx, items); + + const divId = LABKEY.Utils.generateUUID(); + items.push( this.getConceptBrowser(idx, divId)); + }, + + getConceptBrowser: function (idx, divId) { + if (this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI) { + const index = idx; + return { + xtype: 'panel', + layout: 'form', + id: divId, + border: false, + defaults: this.itemDefaults, + items: [{ + value: 'a', + scope: this + }], + listeners: { + render: function() { + // const conceptFilterScript = 'http://localhost:3001/conceptFilter.js'; + const conceptFilterScript = 'gen/conceptFilter'; + LABKEY.requiresScript(conceptFilterScript, this.loadConceptPickers, {divId:divId, index:index, scope:this}); + }, + scope: this + }, + scope: this + }; + } + }, + + getDefaultFilterType: function(idx) { + //Override the default for Concepts unless it is blank + return idx === 0 ? LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE.getURLSuffix() : ''; + }, + + getFilterTypes: function() { + return [ + LABKEY.Filter.Types.HAS_ANY_VALUE, + LABKEY.Filter.Types.EQUAL, + LABKEY.Filter.Types.NEQ_OR_NULL, + LABKEY.Filter.Types.ISBLANK, + LABKEY.Filter.Types.NONBLANK, + LABKEY.Filter.Types.IN, + LABKEY.Filter.Types.NOT_IN, + LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE, + LABKEY.Filter.Types.ONTOLOGY_NOT_IN_SUBTREE + ]; + }, + + enableInputField: function(combo) { + LABKEY.FilterDialog.View.ConceptFilter.superclass.enableInputField.call(this, combo); + + const idx = combo.filterIndex; + const filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); + if (this.updateConceptFilters) { + const updater = this.updateConceptFilters[idx]; + if (updater) { + updater.setFilterType(filter); + } + } + }, + + inputListener : function(input, newVal, oldVal) { + const idx = input.filterIndex; + if (oldVal != newVal) { + this.changed = true; + + const updater = this.updateConceptFilters[idx]; + if (updater) { + updater.setValue(newVal); + } + } + }, + + updateViewReady: function(f) { + LABKEY.FilterDialog.View.ConceptFilter.superclass.updateViewReady.call(this, f); + + // Update concept filters if possible + if (this.updateConceptFilters[f]) { + const filter = this.filters[f]; + const conceptBrowserUpdater = this.updateConceptFilters[f]; + + conceptBrowserUpdater.setValue(filter.getURLParameterValue()); + conceptBrowserUpdater.setFilterType(filter.getFilterType()); + } + } +}); + +Ext.reg('filter-view-conceptfilter', LABKEY.FilterDialog.View.ConceptFilter); + +Ext.ns('LABKEY.ext'); + +LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, { + initComponent : function() { + Ext.apply(this, { + validator: function(val){ + if(!val) + return true; + + return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0."; + } + }); + LABKEY.ext.BooleanTextField.superclass.initComponent.call(this); + } +}); + +Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField); From d93ff6f35104f04c6e3441a3ad4c50e61750ba4c Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 25 Feb 2026 09:51:43 -0800 Subject: [PATCH 2/4] CRLF --- .../study/assay/FileLinkDisplayColumn.java | 954 ++--- api/webapp/clientapi/ext3/FilterDialog.js | 3508 ++++++++--------- 2 files changed, 2231 insertions(+), 2231 deletions(-) diff --git a/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java b/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java index dcc11fd615e..77a147d72b3 100644 --- a/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java +++ b/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java @@ -1,477 +1,477 @@ -/* - * Copyright (c) 2008-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.labkey.api.study.assay; - -import org.apache.commons.lang3.StringUtils; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.labkey.api.admin.CoreUrls; -import org.labkey.api.attachments.Attachment; -import org.labkey.api.data.AbstractFileDisplayColumn; -import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.data.DisplayColumn; -import org.labkey.api.data.RemappingDisplayColumnFactory; -import org.labkey.api.data.RenderContext; -import org.labkey.api.data.TableViewForm; -import org.labkey.api.exp.PropertyDescriptor; -import org.labkey.api.files.FileContentService; -import org.labkey.api.pipeline.PipeRoot; -import org.labkey.api.pipeline.PipelineService; -import org.labkey.api.query.DetailsURL; -import org.labkey.api.query.FieldKey; -import org.labkey.api.query.SchemaKey; -import org.labkey.api.security.User; -import org.labkey.api.security.permissions.ReadPermission; -import org.labkey.api.settings.AppProps; -import org.labkey.api.util.ContainerContext; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.NetworkDrive; -import org.labkey.api.util.PageFlowUtil; -import org.labkey.api.util.Path; -import org.labkey.api.util.URIUtil; -import org.labkey.api.view.ActionURL; -import org.labkey.api.webdav.WebdavResource; -import org.labkey.api.webdav.WebdavService; -import org.labkey.api.writer.HtmlWriter; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; - -public class FileLinkDisplayColumn extends AbstractFileDisplayColumn -{ - // Issue 46282 - let admins choose if files should be rendered inside browser or downloaded as files - public static final String AS_ATTACHMENT_FORMAT = "attachment"; - public static final String AS_INLINE_FORMAT = "inline"; - - public static class Factory implements RemappingDisplayColumnFactory - { - private final Container _container; - - private PropertyDescriptor _pd; - private DetailsURL _detailsUrl; - private SchemaKey _schemaKey; - private String _queryName; - private FieldKey _pkFieldKey; - private FieldKey _objectURIFieldKey; - - public Factory(PropertyDescriptor pd, Container c, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) - { - _pd = pd; - _container = c; - _schemaKey = schemaKey; - _queryName = queryName; - _pkFieldKey = pkFieldKey; - } - - public Factory(PropertyDescriptor pd, Container c, @NotNull FieldKey lsidColumnFieldKey) - { - _pd = pd; - _container = c; - _objectURIFieldKey = lsidColumnFieldKey; - } - - public Factory(DetailsURL detailsURL, Container c, @NotNull FieldKey pkFieldKey) - { - _detailsUrl = detailsURL; - _container = c; - _pkFieldKey = pkFieldKey; - } - - @Override - public Factory remapFieldKeys(@Nullable FieldKey parent, @Nullable Map remap) - { - Factory remapped = this.clone(); - if (remapped._pkFieldKey != null) - { - remapped._pkFieldKey = FieldKey.remap(_pkFieldKey, parent, remap); - if (null == remapped._pkFieldKey) - remapped._pkFieldKey = _pkFieldKey; - } - if (remapped._objectURIFieldKey != null) - { - remapped._objectURIFieldKey = FieldKey.remap(_objectURIFieldKey, parent, remap); - if (null == remapped._objectURIFieldKey) - remapped._objectURIFieldKey = _objectURIFieldKey; - } - return remapped; - } - - @Override - public DisplayColumn createRenderer(ColumnInfo col) - { - if (_pd == null && _detailsUrl != null) - return new FileLinkDisplayColumn(col, _detailsUrl, _container, _pkFieldKey); - else if (_pkFieldKey != null) - return new FileLinkDisplayColumn(col, _pd, _container, _schemaKey, _queryName, _pkFieldKey); - else if (_container != null) - return new FileLinkDisplayColumn(col, _pd, _container, _objectURIFieldKey); - else - throw new IllegalArgumentException("Cannot create a renderer from the specified configuration properties"); - } - - @Override - public FileLinkDisplayColumn.Factory clone() - { - try - { - return (Factory)super.clone(); - } - catch (CloneNotSupportedException e) - { - throw new RuntimeException(e); - } - } - } - - private final Container _container; - - private FieldKey _pkFieldKey; - private FieldKey _objectURIFieldKey; - - /** Use schemaName/queryName and pk FieldKey value to resolve File in CoreController.DownloadFileLinkAction. */ - public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) - { - super(col); - _container = container; - _pkFieldKey = pkFieldKey; - - if (pd.getURL() == null) - { - // Don't stomp over an explicitly configured URL on this column - StringBuilder sb = new StringBuilder("/core/downloadFileLink.view?propertyId="); - sb.append(pd.getPropertyId()); - sb.append("&schemaName="); - sb.append(PageFlowUtil.encodeURIComponent(schemaKey.toString())); - sb.append("&queryName="); - sb.append(PageFlowUtil.encodeURIComponent(queryName)); - sb.append("&pk=${"); - sb.append(pkFieldKey); - sb.append("}"); - sb.append("&modified=${Modified}"); - if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) - { - sb.append("&inline=false"); - } - ContainerContext context = new ContainerContext.FieldKeyContext(new FieldKey(pkFieldKey.getParent(), "Folder")); - setURLExpression(DetailsURL.fromString(sb.toString(), context)); - } - } - - /** Use LSID FieldKey value as ObjectURI to resolve File in CoreController.DownloadFileLinkAction. */ - public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull FieldKey objectURIFieldKey) - { - super(col); - _container = container; - _objectURIFieldKey = objectURIFieldKey; - - if (pd.getURL() == null) - { - // Don't stomp over an explicitly configured URL on this column - - ActionURL baseUrl = PageFlowUtil.urlProvider(CoreUrls.class).getDownloadFileLinkBaseURL(container, pd); - if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) - { - baseUrl.addParameter("inline", "false"); - } - else - { - setLinkTarget("_blank"); - } - DetailsURL url = new DetailsURL(baseUrl, "objectURI", objectURIFieldKey); - setURLExpression(url); - } - } - - public FileLinkDisplayColumn(ColumnInfo col, DetailsURL detailsURL, Container container, @NotNull FieldKey pkFieldKey) - { - super(col); - _container = container; - _pkFieldKey = pkFieldKey; - - setURLExpression(detailsURL); - } - - @Override - protected Object getInputValue(RenderContext ctx) - { - ColumnInfo col = getColumnInfo(); - Object val = null; - TableViewForm viewForm = ctx.getForm(); - - if (col != null) - { - if (null != viewForm && viewForm.contains(this, ctx)) - { - val = viewForm.getAsString(getFormFieldName(ctx)); - } - else if (ctx.getRow() != null) - val = col.getValue(ctx); - } - - return val; - } - - @Override - public void addQueryFieldKeys(Set keys) - { - super.addQueryFieldKeys(keys); - keys.add(FieldKey.fromParts("Modified")); - if (_pkFieldKey != null) - keys.add(_pkFieldKey); - if (_objectURIFieldKey != null) - keys.add(_objectURIFieldKey); - } - - public static boolean filePathExist(String path, Container container, User user) - { - String davPath = path; - if (FileUtil.isUrlEncoded(davPath)) - davPath = FileUtil.decodeURL(davPath); - var resolver = WebdavService.get().getResolver(); - // Resolve path under webdav root - Path parsed = Path.parse(StringUtils.trim(davPath)); - - // Issue 52968: handle context path - Path contextPath = AppProps.getInstance().getParsedContextPath(); - if (parsed.startsWith(contextPath)) - parsed = parsed.subpath(contextPath.size(), parsed.size()); - - WebdavResource resource = resolver.lookup(parsed); - if ((null == resource || !resource.exists()) && !parsed.startsWith(new Path("_webdav"))) - resource = resolver.lookup(new Path("_webdav").append(parsed)); - if (resource != null && resource.isFile() && resource.canRead(user, true)) - { - return true; - } - else - { - // Resolve file under pipeline root - PipeRoot root = PipelineService.get().findPipelineRoot(container); - if (root != null) - { - // Attempt absolute path first, then relative path from pipeline root - File f = new File(path); - if (!root.isUnderRoot(f)) - f = root.resolvePath(path); - - return (NetworkDrive.exists(f) && root.isUnderRoot(f) && root.hasPermission(container, user, ReadPermission.class)); - } - } - - return false; - } - - @Override - protected String getFileName(RenderContext ctx, Object value) - { - return getFileName(ctx, value, false); - } - - @Override - protected String getFileName(RenderContext ctx, Object value, boolean isDisplay) - { - String result = value == null ? null : StringUtils.trimToNull(value.toString()); - if (result != null) - { - File f = null; - if (result.startsWith("file:")) - { - try - { - f = new File(new URI(result)); - } - catch (URISyntaxException x) - { - // try to recover - result = result.substring("file:".length()); - } - } - if (null == f) - f = FileUtil.getAbsoluteCaseSensitiveFile(new File(result)); - NetworkDrive.ensureDrive(f.getPath()); - List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); - boolean valid = false; - List containers = new ArrayList<>(); - containers.add(_container); - // Not ideal, but needed in case data is queried from cross folder context - if (ctx.get("folder") != null || ctx.get("container") != null) - { - Object folderObj = ctx.get("folder"); - if (folderObj == null) - folderObj = ctx.get("container"); - if (folderObj instanceof String containerId) - { - Container dataContainer = ContainerManager.getForId(containerId); - if (dataContainer != null && !dataContainer.equals(_container)) - containers.add(dataContainer); - } - } - for (Container container : containers) - { - if (valid) - break; - - for (FileContentService.ContentType fileRootType : fileRootTypes) - { - result = relativize(f, FileContentService.get().getFileRoot(container, fileRootType)); - if (result != null) - { - // Issue 54062: Strip folder name from displayed name - if (isDisplay) - result = f.getName(); - - valid = true; - break; - } - } - } - if (result == null) - { - result = f.getName(); - } - - if ((!valid || !f.exists()) && !result.endsWith(UNAVAILABLE_FILE_SUFFIX)) - result += UNAVAILABLE_FILE_SUFFIX; - } - return result; - } - - public static String relativize(File f, File fileRoot) - { - if (fileRoot != null) - { - NetworkDrive.ensureDrive(fileRoot.getPath()); - fileRoot = FileUtil.getAbsoluteCaseSensitiveFile(fileRoot); - if (URIUtil.isDescendant(fileRoot.toURI(), f.toURI())) - { - try - { - return FileUtil.relativize(fileRoot, f, false); - } - catch (IOException ignored) {} - } - } - return null; - } - - @Override - protected InputStream getFileContents(RenderContext ctx, Object ignore) throws FileNotFoundException - { - Object value = getValue(ctx); - String s = value == null ? null : StringUtils.trimToNull(value.toString()); - if (s != null) - { - File f = new File(s); - if (f.isFile()) - return new FileInputStream(f); - } - return null; - } - - @Override - protected void renderIconAndFilename( - RenderContext ctx, - HtmlWriter out, - String fileValue /*Could be raw path value, or processed filename by `getFileName`*/, - boolean link, - boolean thumbnail) - { - Object value = getValue(ctx); - String strValue = value == null ? null : StringUtils.trimToNull(value.toString()); - if (strValue != null && !fileValue.endsWith(UNAVAILABLE_FILE_SUFFIX)) - { - File f; - if (strValue.startsWith("file:")) - f = new File(URI.create(strValue)); - else - f = new File(strValue); - - if (!f.exists()) - { - // try all file root - List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); - for (FileContentService.ContentType fileRootType : fileRootTypes) - { - String fullPath = FileContentService.get().getFileRoot(_container, fileRootType).getAbsolutePath()+ File.separator + value; - f = new File(fullPath); - if (f.exists()) - break; - } - } - - // It's probably a file, so check that first - if (f.isFile()) - { - super.renderIconAndFilename(ctx, out, strValue, link, thumbnail); - } - else if (f.isDirectory()) - { - super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(".folder"), null, link, false); - } - else - { - // It's not on the file system anymore, so don't offer a link and tell the user it's unavailable - super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(fileValue), null, false, false); - } - } - else - { - super.renderIconAndFilename(ctx, out, fileValue, link, thumbnail); - } - } - - @Override - public Object getDisplayValue(RenderContext ctx) - { - return getFileName(ctx, super.getDisplayValue(ctx), true); - } - - @Override - public Object getJsonValue(RenderContext ctx) - { - return getFileName(ctx, super.getDisplayValue(ctx)); - } - - @Override - public Object getExportCompatibleValue(RenderContext ctx) - { - return getJsonValue(ctx); - } - - @Override - public boolean isFilterable() - { - return true; - } - @Override - public boolean isSortable() - { - return true; - } - -} +/* + * Copyright (c) 2008-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.labkey.api.study.assay; + +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.labkey.api.admin.CoreUrls; +import org.labkey.api.attachments.Attachment; +import org.labkey.api.data.AbstractFileDisplayColumn; +import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; +import org.labkey.api.data.DisplayColumn; +import org.labkey.api.data.RemappingDisplayColumnFactory; +import org.labkey.api.data.RenderContext; +import org.labkey.api.data.TableViewForm; +import org.labkey.api.exp.PropertyDescriptor; +import org.labkey.api.files.FileContentService; +import org.labkey.api.pipeline.PipeRoot; +import org.labkey.api.pipeline.PipelineService; +import org.labkey.api.query.DetailsURL; +import org.labkey.api.query.FieldKey; +import org.labkey.api.query.SchemaKey; +import org.labkey.api.security.User; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.ContainerContext; +import org.labkey.api.util.FileUtil; +import org.labkey.api.util.NetworkDrive; +import org.labkey.api.util.PageFlowUtil; +import org.labkey.api.util.Path; +import org.labkey.api.util.URIUtil; +import org.labkey.api.view.ActionURL; +import org.labkey.api.webdav.WebdavResource; +import org.labkey.api.webdav.WebdavService; +import org.labkey.api.writer.HtmlWriter; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class FileLinkDisplayColumn extends AbstractFileDisplayColumn +{ + // Issue 46282 - let admins choose if files should be rendered inside browser or downloaded as files + public static final String AS_ATTACHMENT_FORMAT = "attachment"; + public static final String AS_INLINE_FORMAT = "inline"; + + public static class Factory implements RemappingDisplayColumnFactory + { + private final Container _container; + + private PropertyDescriptor _pd; + private DetailsURL _detailsUrl; + private SchemaKey _schemaKey; + private String _queryName; + private FieldKey _pkFieldKey; + private FieldKey _objectURIFieldKey; + + public Factory(PropertyDescriptor pd, Container c, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) + { + _pd = pd; + _container = c; + _schemaKey = schemaKey; + _queryName = queryName; + _pkFieldKey = pkFieldKey; + } + + public Factory(PropertyDescriptor pd, Container c, @NotNull FieldKey lsidColumnFieldKey) + { + _pd = pd; + _container = c; + _objectURIFieldKey = lsidColumnFieldKey; + } + + public Factory(DetailsURL detailsURL, Container c, @NotNull FieldKey pkFieldKey) + { + _detailsUrl = detailsURL; + _container = c; + _pkFieldKey = pkFieldKey; + } + + @Override + public Factory remapFieldKeys(@Nullable FieldKey parent, @Nullable Map remap) + { + Factory remapped = this.clone(); + if (remapped._pkFieldKey != null) + { + remapped._pkFieldKey = FieldKey.remap(_pkFieldKey, parent, remap); + if (null == remapped._pkFieldKey) + remapped._pkFieldKey = _pkFieldKey; + } + if (remapped._objectURIFieldKey != null) + { + remapped._objectURIFieldKey = FieldKey.remap(_objectURIFieldKey, parent, remap); + if (null == remapped._objectURIFieldKey) + remapped._objectURIFieldKey = _objectURIFieldKey; + } + return remapped; + } + + @Override + public DisplayColumn createRenderer(ColumnInfo col) + { + if (_pd == null && _detailsUrl != null) + return new FileLinkDisplayColumn(col, _detailsUrl, _container, _pkFieldKey); + else if (_pkFieldKey != null) + return new FileLinkDisplayColumn(col, _pd, _container, _schemaKey, _queryName, _pkFieldKey); + else if (_container != null) + return new FileLinkDisplayColumn(col, _pd, _container, _objectURIFieldKey); + else + throw new IllegalArgumentException("Cannot create a renderer from the specified configuration properties"); + } + + @Override + public FileLinkDisplayColumn.Factory clone() + { + try + { + return (Factory)super.clone(); + } + catch (CloneNotSupportedException e) + { + throw new RuntimeException(e); + } + } + } + + private final Container _container; + + private FieldKey _pkFieldKey; + private FieldKey _objectURIFieldKey; + + /** Use schemaName/queryName and pk FieldKey value to resolve File in CoreController.DownloadFileLinkAction. */ + public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull SchemaKey schemaKey, @NotNull String queryName, @NotNull FieldKey pkFieldKey) + { + super(col); + _container = container; + _pkFieldKey = pkFieldKey; + + if (pd.getURL() == null) + { + // Don't stomp over an explicitly configured URL on this column + StringBuilder sb = new StringBuilder("/core/downloadFileLink.view?propertyId="); + sb.append(pd.getPropertyId()); + sb.append("&schemaName="); + sb.append(PageFlowUtil.encodeURIComponent(schemaKey.toString())); + sb.append("&queryName="); + sb.append(PageFlowUtil.encodeURIComponent(queryName)); + sb.append("&pk=${"); + sb.append(pkFieldKey); + sb.append("}"); + sb.append("&modified=${Modified}"); + if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) + { + sb.append("&inline=false"); + } + ContainerContext context = new ContainerContext.FieldKeyContext(new FieldKey(pkFieldKey.getParent(), "Folder")); + setURLExpression(DetailsURL.fromString(sb.toString(), context)); + } + } + + /** Use LSID FieldKey value as ObjectURI to resolve File in CoreController.DownloadFileLinkAction. */ + public FileLinkDisplayColumn(ColumnInfo col, PropertyDescriptor pd, Container container, @NotNull FieldKey objectURIFieldKey) + { + super(col); + _container = container; + _objectURIFieldKey = objectURIFieldKey; + + if (pd.getURL() == null) + { + // Don't stomp over an explicitly configured URL on this column + + ActionURL baseUrl = PageFlowUtil.urlProvider(CoreUrls.class).getDownloadFileLinkBaseURL(container, pd); + if (AS_ATTACHMENT_FORMAT.equalsIgnoreCase(col.getFormat())) + { + baseUrl.addParameter("inline", "false"); + } + else + { + setLinkTarget("_blank"); + } + DetailsURL url = new DetailsURL(baseUrl, "objectURI", objectURIFieldKey); + setURLExpression(url); + } + } + + public FileLinkDisplayColumn(ColumnInfo col, DetailsURL detailsURL, Container container, @NotNull FieldKey pkFieldKey) + { + super(col); + _container = container; + _pkFieldKey = pkFieldKey; + + setURLExpression(detailsURL); + } + + @Override + protected Object getInputValue(RenderContext ctx) + { + ColumnInfo col = getColumnInfo(); + Object val = null; + TableViewForm viewForm = ctx.getForm(); + + if (col != null) + { + if (null != viewForm && viewForm.contains(this, ctx)) + { + val = viewForm.getAsString(getFormFieldName(ctx)); + } + else if (ctx.getRow() != null) + val = col.getValue(ctx); + } + + return val; + } + + @Override + public void addQueryFieldKeys(Set keys) + { + super.addQueryFieldKeys(keys); + keys.add(FieldKey.fromParts("Modified")); + if (_pkFieldKey != null) + keys.add(_pkFieldKey); + if (_objectURIFieldKey != null) + keys.add(_objectURIFieldKey); + } + + public static boolean filePathExist(String path, Container container, User user) + { + String davPath = path; + if (FileUtil.isUrlEncoded(davPath)) + davPath = FileUtil.decodeURL(davPath); + var resolver = WebdavService.get().getResolver(); + // Resolve path under webdav root + Path parsed = Path.parse(StringUtils.trim(davPath)); + + // Issue 52968: handle context path + Path contextPath = AppProps.getInstance().getParsedContextPath(); + if (parsed.startsWith(contextPath)) + parsed = parsed.subpath(contextPath.size(), parsed.size()); + + WebdavResource resource = resolver.lookup(parsed); + if ((null == resource || !resource.exists()) && !parsed.startsWith(new Path("_webdav"))) + resource = resolver.lookup(new Path("_webdav").append(parsed)); + if (resource != null && resource.isFile() && resource.canRead(user, true)) + { + return true; + } + else + { + // Resolve file under pipeline root + PipeRoot root = PipelineService.get().findPipelineRoot(container); + if (root != null) + { + // Attempt absolute path first, then relative path from pipeline root + File f = new File(path); + if (!root.isUnderRoot(f)) + f = root.resolvePath(path); + + return (NetworkDrive.exists(f) && root.isUnderRoot(f) && root.hasPermission(container, user, ReadPermission.class)); + } + } + + return false; + } + + @Override + protected String getFileName(RenderContext ctx, Object value) + { + return getFileName(ctx, value, false); + } + + @Override + protected String getFileName(RenderContext ctx, Object value, boolean isDisplay) + { + String result = value == null ? null : StringUtils.trimToNull(value.toString()); + if (result != null) + { + File f = null; + if (result.startsWith("file:")) + { + try + { + f = new File(new URI(result)); + } + catch (URISyntaxException x) + { + // try to recover + result = result.substring("file:".length()); + } + } + if (null == f) + f = FileUtil.getAbsoluteCaseSensitiveFile(new File(result)); + NetworkDrive.ensureDrive(f.getPath()); + List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); + boolean valid = false; + List containers = new ArrayList<>(); + containers.add(_container); + // Not ideal, but needed in case data is queried from cross folder context + if (ctx.get("folder") != null || ctx.get("container") != null) + { + Object folderObj = ctx.get("folder"); + if (folderObj == null) + folderObj = ctx.get("container"); + if (folderObj instanceof String containerId) + { + Container dataContainer = ContainerManager.getForId(containerId); + if (dataContainer != null && !dataContainer.equals(_container)) + containers.add(dataContainer); + } + } + for (Container container : containers) + { + if (valid) + break; + + for (FileContentService.ContentType fileRootType : fileRootTypes) + { + result = relativize(f, FileContentService.get().getFileRoot(container, fileRootType)); + if (result != null) + { + // Issue 54062: Strip folder name from displayed name + if (isDisplay) + result = f.getName(); + + valid = true; + break; + } + } + } + if (result == null) + { + result = f.getName(); + } + + if ((!valid || !f.exists()) && !result.endsWith(UNAVAILABLE_FILE_SUFFIX)) + result += UNAVAILABLE_FILE_SUFFIX; + } + return result; + } + + public static String relativize(File f, File fileRoot) + { + if (fileRoot != null) + { + NetworkDrive.ensureDrive(fileRoot.getPath()); + fileRoot = FileUtil.getAbsoluteCaseSensitiveFile(fileRoot); + if (URIUtil.isDescendant(fileRoot.toURI(), f.toURI())) + { + try + { + return FileUtil.relativize(fileRoot, f, false); + } + catch (IOException ignored) {} + } + } + return null; + } + + @Override + protected InputStream getFileContents(RenderContext ctx, Object ignore) throws FileNotFoundException + { + Object value = getValue(ctx); + String s = value == null ? null : StringUtils.trimToNull(value.toString()); + if (s != null) + { + File f = new File(s); + if (f.isFile()) + return new FileInputStream(f); + } + return null; + } + + @Override + protected void renderIconAndFilename( + RenderContext ctx, + HtmlWriter out, + String fileValue /*Could be raw path value, or processed filename by `getFileName`*/, + boolean link, + boolean thumbnail) + { + Object value = getValue(ctx); + String strValue = value == null ? null : StringUtils.trimToNull(value.toString()); + if (strValue != null && !fileValue.endsWith(UNAVAILABLE_FILE_SUFFIX)) + { + File f; + if (strValue.startsWith("file:")) + f = new File(URI.create(strValue)); + else + f = new File(strValue); + + if (!f.exists()) + { + // try all file root + List fileRootTypes = List.of(FileContentService.ContentType.files, FileContentService.ContentType.pipeline, FileContentService.ContentType.assayfiles); + for (FileContentService.ContentType fileRootType : fileRootTypes) + { + String fullPath = FileContentService.get().getFileRoot(_container, fileRootType).getAbsolutePath()+ File.separator + value; + f = new File(fullPath); + if (f.exists()) + break; + } + } + + // It's probably a file, so check that first + if (f.isFile()) + { + super.renderIconAndFilename(ctx, out, strValue, link, thumbnail); + } + else if (f.isDirectory()) + { + super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(".folder"), null, link, false); + } + else + { + // It's not on the file system anymore, so don't offer a link and tell the user it's unavailable + super.renderIconAndFilename(ctx, out, strValue, Attachment.getFileIcon(fileValue), null, false, false); + } + } + else + { + super.renderIconAndFilename(ctx, out, fileValue, link, thumbnail); + } + } + + @Override + public Object getDisplayValue(RenderContext ctx) + { + return getFileName(ctx, super.getDisplayValue(ctx), true); + } + + @Override + public Object getJsonValue(RenderContext ctx) + { + return getFileName(ctx, super.getDisplayValue(ctx)); + } + + @Override + public Object getExportCompatibleValue(RenderContext ctx) + { + return getJsonValue(ctx); + } + + @Override + public boolean isFilterable() + { + return true; + } + @Override + public boolean isSortable() + { + return true; + } + +} diff --git a/api/webapp/clientapi/ext3/FilterDialog.js b/api/webapp/clientapi/ext3/FilterDialog.js index 175766ec4f9..10190638151 100644 --- a/api/webapp/clientapi/ext3/FilterDialog.js +++ b/api/webapp/clientapi/ext3/FilterDialog.js @@ -1,1754 +1,1754 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -const CONCEPT_CODE_CONCEPT_URI = 'http://www.labkey.org/types#conceptCode'; - -LABKEY.FilterDialog = Ext.extend(Ext.Window, { - - autoHeight: true, - - bbarCfg : { - bodyStyle : 'border-top: 1px solid black;' - }, - - cls: 'labkey-filter-dialog', - - closeAction: 'destroy', - - defaults: { - border: false, - msgTarget: 'under' - }, - - itemId: 'filterWindow', - - modal: true, - - resizable: false, - - // 24846 - width: Ext.isGecko ? 425 : 410, - - allowFacet : undefined, - - cacheFacetResults: true, - - hasOntologyModule: false, - - initComponent : function() { - - if (!this['dataRegionName']) { - console.error('dataRegionName is required for a LABKEY.FilterDialog'); - return; - } - - this.column = this.column || this.boundColumn; // backwards compat - if (!this.configureColumn(this.column)) { - return; - } - - this.hasOntologyModule = LABKEY.moduleContext.api.moduleNames.indexOf('ontology') > -1; - - Ext.apply(this, { - title: this.title || "Show Rows Where " + this.column.caption + "...", - - carryfilter : true, // whether filter state should try to be carried between views (e.g. when changing tabs) - - // buttons - buttons: this.configureButtons(), - - // hook key events - keys:[{ - key: Ext.EventObject.ENTER, - handler: this.onKeyEnter, - scope: this - },{ - key: Ext.EventObject.ESC, - handler: this.closeDialog, - scope: this - }], - width: this.isConceptColumnFilter() ? - (Ext.isGecko ? 613 : 598) : - // 24846 - (Ext.isGecko ? 505 : 490), - // listeners - listeners: { - destroy: function() { - if (this.focusTask) { - Ext.TaskMgr.stop(this.focusTask); - } - }, - resize : function(panel) { panel.syncShadow(); }, - scope : this - } - }); - - this.items = [this.getContainer()]; - - LABKEY.FilterDialog.superclass.initComponent.call(this); - }, - - allowFaceting : function() { - if (Ext.isDefined(this.allowFacet)) - return this.allowFacet; - - var dr = this.getDataRegion(); - if (!this.isQueryDataRegion(dr)) { - this.allowFacet = false; - return this.allowFacet; - } - - this.allowFacet = false; - if (this.column.inputType === 'file') - return this.allowFacet; - - switch (this.column.facetingBehaviorType) { - - case 'ALWAYS_ON': - this.allowFacet = true; - break; - case 'ALWAYS_OFF': - this.allowFacet = false; - break; - case 'AUTOMATIC': - // auto rules are if the column is a lookup or dimension - // OR if it is of type : (boolean, int, date, text), multiline excluded - if (this.column.lookup || this.column.dimension) - this.allowFacet = true; - else if (this.jsonType == 'boolean' || this.jsonType == 'int' || - (this.jsonType == 'string' && this.column.inputType != 'textarea')) - this.allowFacet = true; - break; - } - - return this.allowFacet; - }, - - // Returns an Array of button configurations based on supported operations on this column - configureButtons : function() { - var buttons = [ - {text: 'OK', handler: this.onApply, scope: this}, - {text: 'Cancel', handler: this.closeDialog, scope: this} - ]; - - if (this.getDataRegion()) { - buttons.push({text: 'Clear Filter', handler: this.clearFilter, scope: this}); - buttons.push({text: 'Clear All Filters', handler: this.clearAllFilters, scope: this}); - } - - return buttons; - }, - - // Returns true if the initialization was a success - configureColumn : function(column) { - if (!column) { - console.error('A column is required for LABKEY.FilterDialog'); - return false; - } - - Ext.apply(this, { - // DEPRECATED: Either invoked from GWT, which will handle the commit itself. - // Or invoked as part of a regular filter dialog on a grid - changeFilterCallback: this.confirmCallback, - - fieldCaption: column.caption, - fieldKey: column.lookup && column.displayField ? column.displayField : column.fieldKey, // terrible - jsonType: (column.displayFieldJsonType ? column.displayFieldJsonType : column.jsonType) || 'string' - }); - - return true; - }, - - onKeyEnter : function() { - var view = this.getContainer().getActiveTab(); - var filters = view.getFilters() - if (filters && filters.length > 0) { - var hasMultiValueFilter = false; - filters.forEach(filter => { - var urlSuffix = filter.getFilterType().getURLSuffix(); - if (filter.getFilterType().isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) - hasMultiValueFilter = true; - }) - if (hasMultiValueFilter) - return; - } - - - this.onApply(); - }, - - hasMultiValueFilter: function() { - this._getFilters() - }, - - onApply : function() { - if (this.apply()) - this.closeDialog(); - }, - - // Validates and applies the current filter(s) to the DataRegion - apply : function() { - var view = this.getContainer().getActiveTab(); - var isValid = true; - - if (!view.getForm().isValid()) - isValid = false; - - if (isValid) { - isValid = view.checkValid(); - } - - if (isValid) { - - var dr = this.getDataRegion(), - filters = view.getFilters(); - - if (Ext.isFunction(this.changeFilterCallback)) { - - var filterParams = '', sep = ''; - for (var f=0; f < filters.length; f++) { - filterParams += sep + encodeURIComponent(filters[f].getURLParameterName(this.dataRegionName)) + '=' + encodeURIComponent(filters[f].getURLParameterValue()); - sep = '&'; - } - this.changeFilterCallback.call(this, null, null, filterParams); - } - else { - if (filters.length > 0) { - // add the current filter(s) - if (view.supportsMultipleFilters) { - dr.replaceFilters(filters, this.column); - } - else - dr.replaceFilter(filters[0]); - } - else { - this.clearFilter(); - } - } - } - - return isValid; - }, - - clearFilter : function() { - var dr = this.getDataRegion(); - if (!dr) { return; } - Ext.StoreMgr.clear(); - dr.clearFilter(this.fieldKey); - this.closeDialog(); - }, - - clearAllFilters : function() { - var dr = this.getDataRegion(); - if (!dr) { return; } - dr.clearAllFilters(); - this.closeDialog(); - }, - - closeDialog : function() { - this.close(); - }, - - getDataRegion : function() { - return LABKEY.DataRegions[this.dataRegionName]; - }, - - isQueryDataRegion : function(dr) { - return dr && dr.schemaName && dr.queryName; - }, - - // Returns a class instance of a class that extends Ext.Container. - // This container will hold all the views registered to this FilterDialog instance. - // For caching purposes assign to this.viewcontainer - getContainer : function() { - - if (!this.viewcontainer) { - - var views = this.getViews(); - var type = 'TabPanel'; - - if (views.length == 1) { - views[0].title = false; - type = 'Panel'; - } - - var config = { - defaults: this.defaults, - deferredRender: false, - monitorValid: true, - - // sizing and styling - autoHeight: true, - bodyStyle: 'margin: 0 5px;', - border: true, - items: views - }; - - if (type == 'TabPanel') { - config.listeners = { - beforetabchange : function(tp, newTab, oldTab) { - if (this.carryfilter && newTab && oldTab && oldTab.isChanged()) { - newTab.setFilters(oldTab.getFilters()); - } - }, - tabchange : function() { - this.syncShadow(); - this.viewcontainer.getActiveTab().doLayout(); // required when facets return while on another tab - }, - scope : this - }; - } - - if (views.length > 1) { - config.activeTab = this.getDefaultTab(); - } - else { - views[0].title = false; - } - - this.viewcontainer = new Ext[type](config); - - if (!Ext.isFunction(this.viewcontainer.getActiveTab)) { - var me = this; - this.viewcontainer.getActiveTab = function() { - return me.viewcontainer.items.items[0]; - }; - // views attempt to hook the 'activate' event but some panel types do not fire - // force fire on the first view - this.viewcontainer.items.items[0].on('afterlayout', function(p) { - p.fireEvent('activate', p); - }, this, {single: true}); - } - } - - return this.viewcontainer; - }, - - _getFilters : function() { - var filters = []; - - var dr = this.getDataRegion(); - if (dr) { - Ext.each(dr.getUserFilterArray(), function(ff) { - if (this.column.lookup && this.column.displayField && ff.getColumnName().toLowerCase() === this.column.displayField.toLowerCase()) { - filters.push(ff); - } - else if (this.column.fieldKey && ff.getColumnName().toLowerCase() === this.column.fieldKey.toLowerCase()) { - filters.push(ff); - } - }, this); - } - else if (this.queryString) { // deprecated - filters = LABKEY.Filter.getFiltersFromUrl(this.queryString, this.dataRegionName); - } - - return filters; - }, - - getDefaultTab: function() { - return this.isConceptColumnFilter() ? - 0 : (this.allowFaceting() ? 1 : 0); - }, - - isConceptColumnFilter: function() { - return this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI && this.hasOntologyModule; - }, - - getDefaultView: function(filters) { - const xtypeVal = this.isConceptColumnFilter() - ? 'filter-view-conceptfilter' - : 'filter-view-default'; - - return { - xtype: xtypeVal, - column: this.column, - fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly - dataRegionName: this.dataRegionName, - jsonType : this.jsonType, - filters: filters - }; - }, - - // Override to return your own filter views - getViews : function() { - - const filters = this._getFilters(), views = []; - - // default view - views.push(this.getDefaultView(filters)); - - // facet view - if (this.allowFaceting()) { - views.push({ - xtype: 'filter-view-faceted', - column: this.column, - fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly - dataRegionName: this.dataRegionName, - jsonType : this.jsonType, - filters: filters, - cacheResults: this.cacheFacetResults, - listeners: { - invalidfacetedfilter : function() { - this.carryfilter = false; - this.getContainer().setActiveTab(0); - this.getContainer().getActiveTab().doLayout(); - this.carryfilter = true; - }, - scope: this - }, - scope: this - }) - } - - return views; - } -}); - -LABKEY.FilterDialog.ViewPanel = Ext.extend(Ext.form.FormPanel, { - - supportsMultipleFilters: false, - - filters : [], - - changed : false, - - initComponent : function() { - if (!this['dataRegionName']) { - console.error('dataRegionName is required for a LABKEY.FilterDialog.ViewPanel'); - return; - } - LABKEY.FilterDialog.ViewPanel.superclass.initComponent.call(this); - }, - - // Override to provide own view validation - checkValid : function() { - return true; - }, - - getDataRegion : function() { - return LABKEY.DataRegions[this.dataRegionName]; - }, - - getFilters : function() { - console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement getFilters()'); - }, - - setFilters : function(filterArray) { - console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement setFilters(filterArray)'); - }, - - getXtypes : function() { - const textInputTypes = ['textfield', 'textarea']; - switch (this.jsonType) { - case "date": - return ["datefield"]; - case "int": - case "float": - return textInputTypes; - case "boolean": - return ['labkey-booleantextfield']; - default: - return textInputTypes; - } - }, - - // Returns true if a view has been altered since the last time it was activated - isChanged : function() { - return this.changed; - } -}); - -Ext.ns('LABKEY.FilterDialog.View'); - -LABKEY.FilterDialog.View.Default = Ext.extend(LABKEY.FilterDialog.ViewPanel, { - - supportsMultipleFilters: true, - - itemDefaults: { - border: false, - msgTarget: 'under' - }, - - initComponent : function() { - - Ext.apply(this, { - autoHeight: true, - title: this.title === false ? false : 'Choose Filters', - bodyStyle: 'padding: 5px;', - bubbleEvents: ['add', 'remove', 'clientvalidation'], - defaults: { border: false }, - items: this.generateFilterDisplays(2) - }); - - this.combos = []; - this.inputs = []; - - LABKEY.FilterDialog.View.Default.superclass.initComponent.call(this); - - this.on('activate', this.onViewReady, this, {single: true}); - }, - - updateViewReady: function(f) { - var filter = this.filters[f]; - var combo = this.combos[f]; - - // Update the input enabled/disabled status by using the 'select' event listener on the combobox. - // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. - var store = combo.getStore(); - var filterType = filter.getFilterType(); - var urlSuffix = filterType.getURLSuffix(); - if (store) { - var rec = store.getAt(store.find('value', urlSuffix)); - if (rec) { - combo.setValue(urlSuffix); - combo.fireEvent('select', combo, rec); - } - } - - var inputValue = filter.getURLParameterValue(); - - if (this.jsonType === "date" && inputValue) { - const dateVal = Date.parseDate(inputValue, LABKEY.extDateInputFormat); // date inputs are formatted to ISO date format on server - inputValue = dateVal.format(LABKEY.extDefaultDateFormat); // convert back to date field accepted format for render - } - - // replace multivalued separator (i.e. ;) with \n on UI - if (filterType.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { - var valueSeparator = filterType.getMultiValueSeparator(); - if (typeof inputValue === 'string' && inputValue.indexOf('\n') === -1 && inputValue.indexOf(valueSeparator) > 0) { - inputValue = filterType.parseValue(inputValue); - if (LABKEY.Utils.isArray(inputValue)) - inputValue = inputValue.join('\n'); - } - } - - var inputs = this.getVisibleInputs(); - if (inputs[f]) { - inputs[f].setValue(inputValue); - } - }, - - onViewReady : function() { - var inputs = this.getVisibleInputs(); - if (this.filters.length == 0) { - for (var c=0; c < this.combos.length; c++) { - // Update the input enabled/disabled status by using the 'select' event listener on the combobox. - // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. - this.combos[c].reset(); - this.combos[c].fireEvent('select', this.combos[c], null); - if (inputs[c]) { - inputs[c].reset(); - } - } - } - else { - for (var f=0; f < this.filters.length; f++) { - if (f < this.combos.length) { - this.updateViewReady(f); - } - } - } - - //Issue 24550: always select the first filter field, and also select text if present - if (inputs[0]) { - inputs[0].focus(true, 100, inputs[0]); - } - - this.changed = false; - }, - - getVisibleInputs: function() { - return this.inputs.filter(input => !input.hidden); - }, - - checkValid : function() { - var combos = this.combos; - var inputs = this.getVisibleInputs(), input, value, f; - - var isValid = true; - - Ext.each(combos, function(c, i) { - if (!c.isValid()) { - isValid = false; - } - else { - input = inputs[i]; - value = input.getValue(); - - f = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); - - if (!f) { - alert('filter not found: ' + c.getValue()); - return; - } - - if (f.isDataValueRequired() && Ext.isEmpty(value)) { - input.markInvalid('You must enter a value'); - isValid = false; - } - } - }); - - return isValid; - }, - - inputFieldValidator : function(input, combo) { - - var store = combo.getStore(); - if (store) { - var rec = store.getAt(store.find('value', combo.getValue())); - var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); - - if (rec) { - if (filter.isMultiValued()) - return this.validateMultiValueInput(input.getValue(), filter.getMultiValueSeparator(), filter.getMultiValueMinOccurs(), filter.getMultiValueMaxOccurs()); - return this.validateInputField(input.getValue()); - } - } - return true; - }, - - addFilterConfig: function(idx, items) { - var subItems = [this.getComboConfig(idx)]; - var inputConfigs = this.getInputConfigs(idx); - inputConfigs.forEach(config => { - subItems.push(config); - }); - items.push({ - xtype: 'panel', - layout: 'form', - itemId: 'filterPair' + idx, - border: false, - defaults: this.itemDefaults, - items: subItems, - scope: this - }); - }, - - generateFilterDisplays : function(quantity) { - var idx = this.nextIndex(), items = [], i=0; - - for(; i < quantity; i++) { - this.addFilterConfig(idx, items); - - idx++; - } - - return items; - }, - - getDefaultFilterType: function(idx) { - return idx === 0 ? LABKEY.Filter.getDefaultFilterForType(this.jsonType).getURLSuffix() : ''; - }, - - getComboConfig : function(idx) { - var val = this.getDefaultFilterType(idx); - - return { - xtype: 'combo', - itemId: 'filterComboBox' + idx, - filterIndex: idx, - name: 'filterType_'+(idx + 1), //for compatibility with tests... - listWidth: (this.jsonType == 'date' || this.jsonType == 'boolean') ? null : 380, - emptyText: idx === 0 ? 'Choose a filter:' : 'No other filter', - autoSelect: false, - width: 330, - minListWidth: 330, - triggerAction: 'all', - fieldLabel: (idx === 0 ?'Filter Type' : 'and'), - store: this.getSelectionStore(idx), - displayField: 'text', - valueField: 'value', - typeAhead: 'false', - forceSelection: true, - mode: 'local', - clearFilterOnReset: false, - editable: false, - value: val, - originalValue: val, - listeners : { - render : function(combo) { - this.combos.push(combo); - // Update the associated inputField's enabled/disabled state on initial render - this.enableInputField(combo); - }, - select : function (combo) { - this.changed = true; - this.enableInputField(combo); - }, - scope: this - }, - scope: this - }; - }, - - enableInputField : function (combo) { - - var idx = combo.filterIndex; - var inputField = this.find('itemId', 'inputField'+idx+'-0')[0]; - var textAreaField = this.find('itemId', 'inputField'+idx+'-1')[0]; - - const urlSuffix = combo.getValue().toLowerCase(); - var filter = LABKEY.Filter.getFilterTypeForURLSuffix(urlSuffix); - var selectedValue = filter ? filter.getURLSuffix() : ''; - - var combos = this.combos; - var inputFields = this.inputs; - - if (filter && !filter.isDataValueRequired()) { - //Disable the field and allow it to be blank for values 'isblank' and 'isnonblank'. - inputField.disable(); - inputField.setValue(); - inputField.blur(); - if (textAreaField) - { - textAreaField.disable(); - textAreaField.setValue(); - textAreaField.blur(); - } - } - else { - if (filter.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { - textAreaField.show(); - textAreaField.enable(); - textAreaField.setValue(inputField.getValue()); - textAreaField.validate(); - textAreaField.focus('', 50); - inputField.hide(); - } - else { - inputField.show(); - inputField.enable(); - inputField.setValue(textAreaField && textAreaField.getValue()); - inputField.validate(); - inputField.focus('', 50); - textAreaField && textAreaField.hide(); - } - } - - //if the value is null, this indicates no filter chosen. if it lacks an operator (ie. isBlank) - //in either case, this means we should disable all other filters - if(selectedValue == '' || !filter.isDataValueRequired()){ - //Disable all subsequent combos - Ext.each(combos, function(combo, idx) { - //we enable the next combo in the series - if(combo.filterIndex == this.filterIndex + 1){ - combo.setValue(); - inputFields[idx].setValue(); - inputFields[idx].enable(); - inputFields[idx].validate(); - inputFields[idx].blur(); - } - else if (combo.filterIndex > this.filterIndex){ - combo.setValue(); - inputFields[idx].disable(); - } - - }, this); - } - else{ - //enable the other filterComboBoxes. - Ext.each(combos, function(combo, i) { combo.enable(); }, this); - - if (combos.length) { - combos[0].focus('', 50); - } - } - }, - - getFilters : function() { - - var inputs = this.getVisibleInputs(); - var combos = this.combos; - var value, type, filters = []; - - Ext.each(combos, function(c, i) { - if (!inputs[i].disabled || (c.getRawValue() != 'No Other Filter')) { - value = inputs[i].getValue(); - type = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); - - if (!type) { - alert('Filter not found for suffix: ' + c.getValue()); - } - - // Issue 52068: for multivalued filter types, split on new line to get an array of values - if (value && type.isMultiValued()) { - value = value.indexOf('\n') > -1 ? value.split('\n') : type.parseValue(value); - } - - filters.push(LABKEY.Filter.create(this.fieldKey, value, type)); - } - }, this); - - return filters; - }, - - getAltDateFormats: function() { - if (this.jsonType === "date") - return 'Y-m-d|' + LABKEY.Utils.getDateAltFormats(); // always support ISO - return undefined; - }, - - getInputConfigs : function(idx) { - var me = this; - const xTypes = this.getXtypes(); - var configs = []; - xTypes.forEach((xType, typeId) => { - var config = { - xtype : xType, - itemId : 'inputField' + idx + '-' + typeId, - filterIndex : idx, - id : 'value_'+(idx + 1) + (typeId ? '-' + typeId: ''), //for compatibility with tests... - width : 330, - blankText : 'You must enter a value.', - validateOnBlur: true, - value : null, - altFormats : this.getAltDateFormats(), - hidden: typeId === 1, - disabled: typeId === 1, - emptyText: xType === 'textarea' ? 'Use new line or semicolon to separate entries' : (me.jsonType === 'time' ? 'HH:mm:ss' : undefined), - style: { resize: 'none' }, - validator : function(value) { - - // support for filtering '∞' - if (me.jsonType == 'float' && value.indexOf('∞') > -1) { - value = value.replace('∞', 'Infinity'); - this.setRawValue(value); // does not fire validation - } - - var combos = me.combos; - if (!combos.length) { - return; - } - - return me.inputFieldValidator(this, combos[idx]); - }, - listeners: { - disable : function(field){ - //Call validate after disable so any pre-existing validation errors go away. - if(field.rendered) { - field.validate(); - } - }, - focus : function(f) { - if (this.focusTask) { - Ext.TaskMgr.stop(this.focusTask); - } - }, - render : function(input) { - me.inputs.push(input); - if (!me.focusReady) { - me.focusReady = true; - // create a task to set the input focus that will get started after layout is complete, - // the task will run for a max of 2000ms but will get stopped when the component receives focus - this.focusTask = {interval:150, run: function(){ - input.focus(null, 50); - Ext.TaskMgr.stop(this.focusTask); - }, scope: this, duration: 2000}; - } - }, - change : this.inputListener, - scope : this - }, - scope: this - }; - if (this.jsonType === "date") { - config.format = LABKEY.extDefaultDateFormat; - - // default invalidText : "{0} is not a valid date - it must be in the format {1}", - // override the default warning msg as there is one preferred format, but there are also a set of acceptable altFormats - config.invalidText = "{0} might not be a valid date - the preferred format is {1}"; - } - - configs.push(config); - }) - return configs; - }, - - inputListener : function(input, newVal, oldVal) { - if (oldVal != newVal) { - this.changed = true; - } - }, - - getFilterTypes: function() { - return LABKEY.Filter.getFilterTypesForType(this.jsonType, this.column.mvEnabled); - }, - - getSelectionStore : function(storeNum) { - var fields = ['text', 'value', - {name: 'isMulti', type: Ext.data.Types.BOOL}, - {name: 'isOperatorOnly', type: Ext.data.Types.BOOL} - ]; - var store = new Ext.data.ArrayStore({ - fields: fields, - idIndex: 1 - }); - var comboRecord = Ext.data.Record.create(fields); - - var filters = this.getFilterTypes(); - - for (var i=0; i 0) { - store.removeAt(0); - store.insert(0, new comboRecord({text:'No Other Filter', value: ''})); - } - - return store; - }, - - setFilters : function(filterArray) { - this.filters = filterArray; - this.onViewReady(); - }, - - nextIndex : function() { - return 0; - }, - - validateMultiValueInput : function(inputValues, multiValueSeparator, minOccurs, maxOccurs) { - if (!inputValues) - return true; - // Used when "Equals One Of.." or "Between" is selected. Calls validateInputField on each value entered. - const sep = inputValues.indexOf('\n') > 0 ? '\n' : multiValueSeparator; - var values = inputValues.split(sep); - var isValid = ""; - for(var i = 0; i < values.length; i++){ - isValid = this.validateInputField(values[i]); - if(isValid !== true){ - return isValid; - } - } - - if (minOccurs !== undefined && minOccurs > 0) - { - if (values.length < minOccurs) - return "At least " + minOccurs + " '" + multiValueSeparator + "' separated values are required"; - } - - if (maxOccurs !== undefined && maxOccurs > 0) - { - if (values.length > maxOccurs) - return "At most " + maxOccurs + " '" + multiValueSeparator + "' separated values are allowed"; - } - - if (!Ext.isEmpty(inputValues) && typeof inputValues === 'string' && inputValues.trim().length > 2000) - return "Value is too long"; - - //If we make it out of the for loop we had no errors. - return true; - }, - - // The fact that Ext3 ties validation to the editor is a little funny, - // but using this shifts the work to Ext - validateInputField : function(value) { - var map = { - 'string': 'STRING', - 'time': 'STRING', - 'int': 'INT', - 'float': 'FLOAT', - 'date': 'DATE', - 'boolean': 'BOOL' - }; - var type = map[this.jsonType]; - if (type) { - var field = new Ext.data.Field({ - type: Ext.data.Types[type], - allowDecimals : this.jsonType != "int", //will be ignored by anything besides numberfield - useNull: true - }); - - var values = (!Ext.isEmpty(value) && typeof value === 'string' && value.indexOf('\n') > -1) ? value.split('\n') : [value]; - var invalid = null; - values.forEach(val => { - if (val == null) - return; - var convertedVal = field.convert(val); - if (!Ext.isEmpty(val) && val != convertedVal) { - invalid = val; - } - }) - - if (invalid != null) - return "Invalid value: " + invalid; - - if (!Ext.isEmpty(value) && typeof value === 'string' && value.trim().length > 2000) - return "Value is too long"; - } - else { - if (this.jsonType.toLowerCase() !== 'array') - console.log('Unrecognized type: ' + this.jsonType); - } - - return true; - } -}); - -Ext.reg('filter-view-default', LABKEY.FilterDialog.View.Default); - -LABKEY.FilterDialog.View.Faceted = Ext.extend(LABKEY.FilterDialog.ViewPanel, { - - MAX_FILTER_CHOICES: 250, // This is the maximum number of filters that will be requested / shown - - applyContextFilters: true, - - /** - * Logically convert filters to try and optimize the query on the server. - * (e.g. using NOT IN when less than half the available values are checked) - */ - filterOptimization: true, - - cacheResults: true, - - emptyDisplayValue: '[Blank]', - - gridID: Ext.id(), - - loadError: undefined, - - overflow: false, - - initComponent : function() { - - Ext.apply(this, { - title : 'Choose Values', - border : false, - height : 200, - bodyStyle: 'overflow-x: hidden; overflow-y: auto', - bubbleEvents: ['add', 'remove', 'clientvalidation'], - defaults : { - border : false - }, - markDisabled : true, - items: [{ - layout: 'hbox', - style: 'padding-bottom: 5px; overflow-x: hidden', - defaults: { - border: false - }, - items: [{ - xtype: 'box', - cls: 'alert alert-danger', - hidden: true, - id: this.gridID + '-error', - style: 'position: relative;', - },{ - xtype: 'label', - id: this.gridID + 'OverflowLabel', - hidden: true, - text: 'There are more than ' + this.MAX_FILTER_CHOICES + ' values. Showing a partial list.' - }] - }] - }); - - LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this); - - this.on('render', this.onPanelRender, this, {single: true}); - }, - - formatValue : function(val) { - if(this.column) { - if (this.column.extFormatFn) { - try { - this.column.extFormatFn = eval(this.column.extFormatFn); - } - catch (error) { - console.log('improper extFormatFn: ' + this.column.extFormatFn); - } - - if (Ext.isFunction(this.column.extFormatFn)) { - val = this.column.extFormatFn(val); - } - } - else if (this.jsonType == 'int') { - val = parseInt(val); - } - } - return val; - }, - - // copied from Ext 4 Ext.Array.difference - difference : function(arrayA, arrayB) { - var clone = arrayA.slice(), - ln = clone.length, - i, j, lnB; - - for (i = 0,lnB = arrayB.length; i < lnB; i++) { - for (j = 0; j < ln; j++) { - if (clone[j] === arrayB[i]) { - clone.splice(j, 1); - j--; - ln--; - } - } - } - - return clone; - }, - - constructFilter : function(selected, unselected) { - var filter = null; - - if (selected.length > 0) { - - var columnName = this.fieldKey; - - // one selection - if (selected.length == 1) { - if (selected[0].get('displayValue') == this.emptyDisplayValue) - filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK); - else - filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL - } - else if (this.filterOptimization && selected.length > unselected.length) { - // Do the negation - if (unselected.length == 1) { - var val = unselected[0].get('value'); - var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING); - - // 18716: Check if 'unselected' contains empty value - filter = LABKEY.Filter.create(columnName, val, type); - } - else - filter = LABKEY.Filter.create(columnName, this.selectedToValues(unselected), LABKEY.Filter.Types.NOT_IN); - } - else { - filter = LABKEY.Filter.create(columnName, this.selectedToValues(selected), LABKEY.Filter.Types.IN); - } - } - - return filter; - }, - - // get array of values from the selected store item array - selectedToValues : function(valueArray) { - return valueArray.map(function (i) { return i.get('value'); }); - }, - - // Implement interface LABKEY.FilterDialog.ViewPanel - getFilters : function() { - var grid = Ext.getCmp(this.gridID); - var filters = []; - - if (grid) { - var store = grid.store; - var count = store.getCount(); // TODO: Check if store loaded - var selected = grid.getSelectionModel().getSelections(); - - if (count == 0 || selected.length == 0 || selected.length == count) { - filters = []; - } - else { - var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : []; - filters = [this.constructFilter(selected, unselected)]; - } - } - - return filters; - }, - - // Implement interface LABKEY.FilterDialog.ViewPanel - setFilters : function(filterArray) { - if (Ext.isArray(filterArray)) { - this.filters = filterArray; - this.onViewReady(); - } - }, - - getGridConfig : function(idx) { - var sm = new Ext.grid.CheckboxSelectionModel({ - listeners: { - selectionchange: { - fn: function(sm) { - // NOTE: this will manually set the checked state of the header checkbox. it would be better - // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3 - // and ext4 will be quite different it doesnt seem worth the effort right now - var selections = sm.getSelections(); - var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div'); - if(selections.length == sm.grid.store.getCount()){ - headerCell.addClass('x-grid3-hd-checker-on'); - } - else { - headerCell.removeClass('x-grid3-hd-checker-on'); - } - - - }, - buffer: 50 - } - } - }); - - var me = this; - - return { - xtype: 'grid', - id: this.gridID, - border: true, - bodyBorder: true, - frame: false, - autoHeight: true, - itemId: 'inputField' + (idx || 0), - filterIndex: idx || 0, - msgTarget: 'title', - store: this.getLookupStore(), - headerClick: false, - viewConfig: { - headerTpl: new Ext.Template( - '', - '', - '{cells}', - '', - '
' - ) - }, - sm: sm, - cls: 'x-grid-noborder', - columns: [ - sm, - new Ext.grid.TemplateColumn({ - header: '[All]', - dataIndex: 'value', - menuDisabled: true, - resizable: false, - width: 340, - tpl: new Ext.XTemplate('' + - '' + - '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' + - '') - }) - ], - listeners: { - afterrender : function(grid) { - grid.getSelectionModel().on('selectionchange', function() { - this.changed = true; - }, this); - - grid.on('viewready', function(g) { - this.gridReady = true; - this.onViewReady(); - }, this, {single: true}); - }, - scope : this - }, - // extend toggle behavior to the header cell, not just the checkbox next to it - onHeaderCellClick : function() { - var sm = this.getSelectionModel(); - var selected = sm.getSelections(); - selected.length == this.store.getCount() ? this.selectNone() : this.selectAll(); - }, - getValue : function() { - var vals = this.getValues(); - if (vals.length == vals.max) { - return []; - } - return vals.values; - }, - getValues : function() { - var values = [], - sels = this.getSelectionModel().getSelections(); - - Ext.each(sels, function(rec){ - values.push(rec.get('strValue')); - }, this); - - if(values.indexOf('') != -1 && values.length == 1) - values.push(''); //account for null-only filtering - - return { - values : values.join(';'), - length : values.length, - max : this.getStore().getCount() - }; - }, - setValue : function(values, negated) { - if (!this.rendered) { - this.on('render', function() { - this.setValue(values, negated); - }, this, {single: true}); - } - - if (!Ext.isArray(values)) { - values = values.split(';'); - } - - if (this.store.isLoading) { - // need to wait for the store to load to ensure records - this.store.on('load', function() { - this._checkAndLoadValues(values, negated); - }, this, {single: true}); - } - else { - this._checkAndLoadValues(values, negated); - } - }, - _checkAndLoadValues : function(values, negated) { - var records = [], - recIdx, - recordNotFound = false; - - Ext.each(values, function(val) { - recIdx = this.store.findBy(function(rec){ - return rec.get('strValue') === val; - }); - - if (recIdx != -1) { - records.push(recIdx); - } - else { - // Issue 14710: if the record isnt found, we wont be able to select it, so should reject. - // If it's null/empty, ignore silently - if (!Ext.isEmpty(val)) { - recordNotFound = true; - return false; - } - } - }, this); - - if (negated) { - var count = this.store.getCount(), found = false, negRecords = []; - for (var i=0; i < count; i++) { - found = false; - for (var j=0; j < records.length; j++) { - if (records[j] == i) - found = true; - } - if (!found) { - negRecords.push(i); - } - } - records = negRecords; - } - - if (recordNotFound) { - // cannot find any matching records - if (me.column.facetingBehaviorType != 'ALWAYS_ON') - me.fireEvent('invalidfacetedfilter'); - return; - } - - this.getSelectionModel().selectRows(records); - }, - selectAll : function() { - if (this.rendered) { - var sm = this.getSelectionModel(); - sm.selectAll.defer(10, sm); - } - else { - this.on('render', this.selectAll, this, {single: true}); - } - }, - selectNone : function() { - if (this.rendered) { - this.getSelectionModel().selectRows([]); - } - else { - this.on('render', this.selectNone, this, {single: true}); - } - }, - determineNegation: function(filter) { - var suffix = filter.getFilterType().getURLSuffix(); - var negated = suffix == 'neqornull' || suffix == 'notin'; - - // negation of the null case is a bit different so check it as a special case. - var value = filter.getURLParameterValue(); - if (value == "" && suffix != 'isblank') { - negated = true; - } - return negated; - }, - selectFilter : function(filter) { - var negated = this.determineNegation(filter); - - this.setValue(filter.getURLParameterValue(), negated); - - if (!me.filterOptimization && negated) { - me.fireEvent('invalidfacetedfilter'); - } - }, - scope : this - }; - }, - - shouldShowFaceted : function(filter) { - const CHOOSE_VALUE_FILTERS = [ - LABKEY.Filter.Types.EQUAL.getURLSuffix(), - LABKEY.Filter.Types.IN.getURLSuffix(), - LABKEY.Filter.Types.NEQ.getURLSuffix(), - LABKEY.Filter.Types.NEQ_OR_NULL.getURLSuffix(), - LABKEY.Filter.Types.NOT_IN.getURLSuffix(), - ]; - - if (!filter) - return true; - - return CHOOSE_VALUE_FILTERS.indexOf(filter.getFilterType().getURLSuffix()) >= 0; - }, - - onViewReady : function() { - if (this.gridReady && this.storeReady) { - var grid = Ext.getCmp(this.gridID); - this.hideMask(); - - if (grid) { - - var numFilters = this.filters.length; - var numFacets = grid.store.getCount(); - - // apply current filter - if (numFacets == 0) - grid.selectNone(); - else if (numFilters == 0) - grid.selectAll(); - else - grid.selectFilter(this.filters[0]); - - // Issue 52547: LKS filter dialog treats many filter types as if they are Equals - if (numFilters > 1 || !this.shouldShowFaceted(this.filters[0])) - this.fireEvent('invalidfacetedfilter'); - - if (!grid.headerClick) { - grid.headerClick = true; - var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div'); - div.on('click', grid.onHeaderCellClick, grid); - } - - if (this.loadError) { - var errorCmp = Ext.getCmp(this.gridID + '-error'); - errorCmp.update(this.loadError); - errorCmp.setVisible(true); - } - - // Issue 39727 - show a message if we've capped the number of options shown - Ext.getCmp(this.gridID + 'OverflowLabel').setVisible(this.overflow); - - if (this.loadError || this.overflow) { - this.fireEvent('invalidfacetedfilter'); - } - } - } - - this.changed = false; - }, - - getLookupStore : function() { - var dr = this.getDataRegion(); - var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id(); - - // cache - var store = Ext.StoreMgr.get(storeId); - if (store) { - this.storeReady = true; // unsafe - return store; - } - - store = new Ext.data.ArrayStore({ - fields : ['value', 'strValue', 'displayValue'], - storeId: storeId - }); - - var config = { - schemaName: dr.schemaName, - queryName: dr.queryName, - dataRegionName: dr.name, - viewName: dr.viewName, - column: this.fieldKey, - filterArray: dr.filters, - containerPath: dr.container || dr.containerPath || LABKEY.container.path, - containerFilter: dr.getContainerFilter(), - parameters: dr.getParameters(), - maxRows: this.MAX_FILTER_CHOICES+1, - ignoreFilter: dr.ignoreFilter, - success : function(d) { - if (d && d.values) { - var recs = [], v, i=0, hasBlank = false, isString, formattedValue; - - // Issue 39727 - remember if we exceeded our cap so we can show a message - this.overflow = d.values.length > this.MAX_FILTER_CHOICES; - - for (; i < Math.min(d.values.length, this.MAX_FILTER_CHOICES); i++) { - v = d.values[i]; - formattedValue = this.formatValue(v); - isString = Ext.isString(formattedValue); - - if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) { - hasBlank = true; - } - else if (Ext.isDefined(v)) { - recs.push([v, v.toString(), v.toString()]); - } - } - - if (hasBlank) - recs.unshift(['', '', this.emptyDisplayValue]); - - store.loadData(recs); - store.isLoading = false; - this.storeReady = true; - this.onViewReady(); - } - }, - failure: function(err) { - if (err && err.exception) { - this.loadError = err.exception; - } else { - this.loadError = 'Failed to load faceted data.'; - } - store.isLoading = false; - this.storeReady = true; - this.onViewReady(); - }, - scope: this - }; - - if (this.applyContextFilters) { - var userFilters = dr.getUserFilterArray(); - if (userFilters && userFilters.length > 0) { - - var uf = []; - - // Remove filters for the current column - for (var i=0; i < userFilters.length; i++) { - if (userFilters[i].getColumnName() != this.fieldKey) { - uf.push(userFilters[i]); - } - } - - config.filterArray = uf; - } - } - - // Use Select Distinct - LABKEY.Query.selectDistinctRows(config); - - return Ext.StoreMgr.add(store); - }, - - onPanelRender : function(panel) { - var toAdd = [{ - xtype: 'panel', - width: this.width - 40, //prevent horizontal scroll - bodyStyle: 'padding-left: 5px;', - items: [ this.getGridConfig(0) ], - listeners : { - afterrender : { - fn: this.showMask, - scope: this, - single: true - } - } - }]; - panel.add(toAdd); - }, - - showMask : function() { - if (!this.gridReady && this.getEl()) { - this.getEl().mask('Loading...'); - } - }, - - hideMask : function() { - if (this.getEl()) { - this.getEl().unmask(); - } - } -}); - -Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted); - -LABKEY.FilterDialog.View.ConceptFilter = Ext.extend(LABKEY.FilterDialog.View.Default, { - - initComponent: function () { - this.updateConceptFilters = []; - - LABKEY.FilterDialog.View.ConceptFilter.superclass.initComponent.call(this); - }, - - getListenerConfig: function(index) { - if (!this.updateConceptFilters[index]) { - this.updateConceptFilters[index] = {filterIndex: index}; - } - - return this.updateConceptFilters[index]; - }, - - //Callback from RequireScripts is passed a contextual this object - loadConceptPickers: function() { - const ctx = this; - const divId = ctx.divId, - index = ctx.index, - scope = ctx.scope; - - LABKEY.App.loadApp('conceptFilter', divId, { - ontologyId: scope.column.sourceOntology, - conceptSubtree: scope.column.conceptSubtree, - columnName: scope.column.caption, - onFilterChange: function(filterValue) { - // Inputs may be set after app load, so look it up at execution time - const inputs = scope.inputs; - if (!inputs) - return; - - const textInput = inputs[index * 2]; // one text input, one textarea input - const textAreaInput = inputs[index * 2 + 1]; - const targetInput = textInput && !textInput.hidden ? textInput: textAreaInput; - - // push values selected in tree to the target input control - if (targetInput && !targetInput.disabled) { - targetInput.setValue(filterValue); - targetInput.validate(); - } - }, - subscribeFilterValue: function(listener) { - scope.getListenerConfig(index).setValue = listener; - this.changed = true; - }, - unsubscribeFilterValue: function() { - scope.getListenerConfig(index).setValue = undefined; - }, - subscribeFilterTypeChanged: function(listener) { - scope.getListenerConfig(index).setFilterType = listener; - this.changed = true; - }, - unsubscribeFilterTypeChanged: function() { - scope.getListenerConfig(index).setFilterType = undefined; - }, - loadListener: function() { - scope.onViewReady(); // TODO be a little more targeted, but this ensures the filtertype & filterValue parameters get set because the Ext elements get rendered & set async - }, - subscribeCollapse: function(listener) { - scope.getListenerConfig(index).collapsePanel = listener; - }, - unsubscribeCollapse: function() { - scope.getListenerConfig(index).collapsePanel = undefined; - }, - onOpen: function() { - scope.updateConceptFilters.forEach( function(panel) { - if (panel.filterIndex !== index) panel.collapsePanel(); - }); - } - }); - }, - - addFilterConfig: function(idx, items) { - LABKEY.FilterDialog.View.ConceptFilter.superclass.addFilterConfig.call(this, idx, items); - - const divId = LABKEY.Utils.generateUUID(); - items.push( this.getConceptBrowser(idx, divId)); - }, - - getConceptBrowser: function (idx, divId) { - if (this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI) { - const index = idx; - return { - xtype: 'panel', - layout: 'form', - id: divId, - border: false, - defaults: this.itemDefaults, - items: [{ - value: 'a', - scope: this - }], - listeners: { - render: function() { - // const conceptFilterScript = 'http://localhost:3001/conceptFilter.js'; - const conceptFilterScript = 'gen/conceptFilter'; - LABKEY.requiresScript(conceptFilterScript, this.loadConceptPickers, {divId:divId, index:index, scope:this}); - }, - scope: this - }, - scope: this - }; - } - }, - - getDefaultFilterType: function(idx) { - //Override the default for Concepts unless it is blank - return idx === 0 ? LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE.getURLSuffix() : ''; - }, - - getFilterTypes: function() { - return [ - LABKEY.Filter.Types.HAS_ANY_VALUE, - LABKEY.Filter.Types.EQUAL, - LABKEY.Filter.Types.NEQ_OR_NULL, - LABKEY.Filter.Types.ISBLANK, - LABKEY.Filter.Types.NONBLANK, - LABKEY.Filter.Types.IN, - LABKEY.Filter.Types.NOT_IN, - LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE, - LABKEY.Filter.Types.ONTOLOGY_NOT_IN_SUBTREE - ]; - }, - - enableInputField: function(combo) { - LABKEY.FilterDialog.View.ConceptFilter.superclass.enableInputField.call(this, combo); - - const idx = combo.filterIndex; - const filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); - if (this.updateConceptFilters) { - const updater = this.updateConceptFilters[idx]; - if (updater) { - updater.setFilterType(filter); - } - } - }, - - inputListener : function(input, newVal, oldVal) { - const idx = input.filterIndex; - if (oldVal != newVal) { - this.changed = true; - - const updater = this.updateConceptFilters[idx]; - if (updater) { - updater.setValue(newVal); - } - } - }, - - updateViewReady: function(f) { - LABKEY.FilterDialog.View.ConceptFilter.superclass.updateViewReady.call(this, f); - - // Update concept filters if possible - if (this.updateConceptFilters[f]) { - const filter = this.filters[f]; - const conceptBrowserUpdater = this.updateConceptFilters[f]; - - conceptBrowserUpdater.setValue(filter.getURLParameterValue()); - conceptBrowserUpdater.setFilterType(filter.getFilterType()); - } - } -}); - -Ext.reg('filter-view-conceptfilter', LABKEY.FilterDialog.View.ConceptFilter); - -Ext.ns('LABKEY.ext'); - -LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, { - initComponent : function() { - Ext.apply(this, { - validator: function(val){ - if(!val) - return true; - - return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0."; - } - }); - LABKEY.ext.BooleanTextField.superclass.initComponent.call(this); - } -}); - -Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField); +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +const CONCEPT_CODE_CONCEPT_URI = 'http://www.labkey.org/types#conceptCode'; + +LABKEY.FilterDialog = Ext.extend(Ext.Window, { + + autoHeight: true, + + bbarCfg : { + bodyStyle : 'border-top: 1px solid black;' + }, + + cls: 'labkey-filter-dialog', + + closeAction: 'destroy', + + defaults: { + border: false, + msgTarget: 'under' + }, + + itemId: 'filterWindow', + + modal: true, + + resizable: false, + + // 24846 + width: Ext.isGecko ? 425 : 410, + + allowFacet : undefined, + + cacheFacetResults: true, + + hasOntologyModule: false, + + initComponent : function() { + + if (!this['dataRegionName']) { + console.error('dataRegionName is required for a LABKEY.FilterDialog'); + return; + } + + this.column = this.column || this.boundColumn; // backwards compat + if (!this.configureColumn(this.column)) { + return; + } + + this.hasOntologyModule = LABKEY.moduleContext.api.moduleNames.indexOf('ontology') > -1; + + Ext.apply(this, { + title: this.title || "Show Rows Where " + this.column.caption + "...", + + carryfilter : true, // whether filter state should try to be carried between views (e.g. when changing tabs) + + // buttons + buttons: this.configureButtons(), + + // hook key events + keys:[{ + key: Ext.EventObject.ENTER, + handler: this.onKeyEnter, + scope: this + },{ + key: Ext.EventObject.ESC, + handler: this.closeDialog, + scope: this + }], + width: this.isConceptColumnFilter() ? + (Ext.isGecko ? 613 : 598) : + // 24846 + (Ext.isGecko ? 505 : 490), + // listeners + listeners: { + destroy: function() { + if (this.focusTask) { + Ext.TaskMgr.stop(this.focusTask); + } + }, + resize : function(panel) { panel.syncShadow(); }, + scope : this + } + }); + + this.items = [this.getContainer()]; + + LABKEY.FilterDialog.superclass.initComponent.call(this); + }, + + allowFaceting : function() { + if (Ext.isDefined(this.allowFacet)) + return this.allowFacet; + + var dr = this.getDataRegion(); + if (!this.isQueryDataRegion(dr)) { + this.allowFacet = false; + return this.allowFacet; + } + + this.allowFacet = false; + if (this.column.inputType === 'file') + return this.allowFacet; + + switch (this.column.facetingBehaviorType) { + + case 'ALWAYS_ON': + this.allowFacet = true; + break; + case 'ALWAYS_OFF': + this.allowFacet = false; + break; + case 'AUTOMATIC': + // auto rules are if the column is a lookup or dimension + // OR if it is of type : (boolean, int, date, text), multiline excluded + if (this.column.lookup || this.column.dimension) + this.allowFacet = true; + else if (this.jsonType == 'boolean' || this.jsonType == 'int' || + (this.jsonType == 'string' && this.column.inputType != 'textarea')) + this.allowFacet = true; + break; + } + + return this.allowFacet; + }, + + // Returns an Array of button configurations based on supported operations on this column + configureButtons : function() { + var buttons = [ + {text: 'OK', handler: this.onApply, scope: this}, + {text: 'Cancel', handler: this.closeDialog, scope: this} + ]; + + if (this.getDataRegion()) { + buttons.push({text: 'Clear Filter', handler: this.clearFilter, scope: this}); + buttons.push({text: 'Clear All Filters', handler: this.clearAllFilters, scope: this}); + } + + return buttons; + }, + + // Returns true if the initialization was a success + configureColumn : function(column) { + if (!column) { + console.error('A column is required for LABKEY.FilterDialog'); + return false; + } + + Ext.apply(this, { + // DEPRECATED: Either invoked from GWT, which will handle the commit itself. + // Or invoked as part of a regular filter dialog on a grid + changeFilterCallback: this.confirmCallback, + + fieldCaption: column.caption, + fieldKey: column.lookup && column.displayField ? column.displayField : column.fieldKey, // terrible + jsonType: (column.displayFieldJsonType ? column.displayFieldJsonType : column.jsonType) || 'string' + }); + + return true; + }, + + onKeyEnter : function() { + var view = this.getContainer().getActiveTab(); + var filters = view.getFilters() + if (filters && filters.length > 0) { + var hasMultiValueFilter = false; + filters.forEach(filter => { + var urlSuffix = filter.getFilterType().getURLSuffix(); + if (filter.getFilterType().isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) + hasMultiValueFilter = true; + }) + if (hasMultiValueFilter) + return; + } + + + this.onApply(); + }, + + hasMultiValueFilter: function() { + this._getFilters() + }, + + onApply : function() { + if (this.apply()) + this.closeDialog(); + }, + + // Validates and applies the current filter(s) to the DataRegion + apply : function() { + var view = this.getContainer().getActiveTab(); + var isValid = true; + + if (!view.getForm().isValid()) + isValid = false; + + if (isValid) { + isValid = view.checkValid(); + } + + if (isValid) { + + var dr = this.getDataRegion(), + filters = view.getFilters(); + + if (Ext.isFunction(this.changeFilterCallback)) { + + var filterParams = '', sep = ''; + for (var f=0; f < filters.length; f++) { + filterParams += sep + encodeURIComponent(filters[f].getURLParameterName(this.dataRegionName)) + '=' + encodeURIComponent(filters[f].getURLParameterValue()); + sep = '&'; + } + this.changeFilterCallback.call(this, null, null, filterParams); + } + else { + if (filters.length > 0) { + // add the current filter(s) + if (view.supportsMultipleFilters) { + dr.replaceFilters(filters, this.column); + } + else + dr.replaceFilter(filters[0]); + } + else { + this.clearFilter(); + } + } + } + + return isValid; + }, + + clearFilter : function() { + var dr = this.getDataRegion(); + if (!dr) { return; } + Ext.StoreMgr.clear(); + dr.clearFilter(this.fieldKey); + this.closeDialog(); + }, + + clearAllFilters : function() { + var dr = this.getDataRegion(); + if (!dr) { return; } + dr.clearAllFilters(); + this.closeDialog(); + }, + + closeDialog : function() { + this.close(); + }, + + getDataRegion : function() { + return LABKEY.DataRegions[this.dataRegionName]; + }, + + isQueryDataRegion : function(dr) { + return dr && dr.schemaName && dr.queryName; + }, + + // Returns a class instance of a class that extends Ext.Container. + // This container will hold all the views registered to this FilterDialog instance. + // For caching purposes assign to this.viewcontainer + getContainer : function() { + + if (!this.viewcontainer) { + + var views = this.getViews(); + var type = 'TabPanel'; + + if (views.length == 1) { + views[0].title = false; + type = 'Panel'; + } + + var config = { + defaults: this.defaults, + deferredRender: false, + monitorValid: true, + + // sizing and styling + autoHeight: true, + bodyStyle: 'margin: 0 5px;', + border: true, + items: views + }; + + if (type == 'TabPanel') { + config.listeners = { + beforetabchange : function(tp, newTab, oldTab) { + if (this.carryfilter && newTab && oldTab && oldTab.isChanged()) { + newTab.setFilters(oldTab.getFilters()); + } + }, + tabchange : function() { + this.syncShadow(); + this.viewcontainer.getActiveTab().doLayout(); // required when facets return while on another tab + }, + scope : this + }; + } + + if (views.length > 1) { + config.activeTab = this.getDefaultTab(); + } + else { + views[0].title = false; + } + + this.viewcontainer = new Ext[type](config); + + if (!Ext.isFunction(this.viewcontainer.getActiveTab)) { + var me = this; + this.viewcontainer.getActiveTab = function() { + return me.viewcontainer.items.items[0]; + }; + // views attempt to hook the 'activate' event but some panel types do not fire + // force fire on the first view + this.viewcontainer.items.items[0].on('afterlayout', function(p) { + p.fireEvent('activate', p); + }, this, {single: true}); + } + } + + return this.viewcontainer; + }, + + _getFilters : function() { + var filters = []; + + var dr = this.getDataRegion(); + if (dr) { + Ext.each(dr.getUserFilterArray(), function(ff) { + if (this.column.lookup && this.column.displayField && ff.getColumnName().toLowerCase() === this.column.displayField.toLowerCase()) { + filters.push(ff); + } + else if (this.column.fieldKey && ff.getColumnName().toLowerCase() === this.column.fieldKey.toLowerCase()) { + filters.push(ff); + } + }, this); + } + else if (this.queryString) { // deprecated + filters = LABKEY.Filter.getFiltersFromUrl(this.queryString, this.dataRegionName); + } + + return filters; + }, + + getDefaultTab: function() { + return this.isConceptColumnFilter() ? + 0 : (this.allowFaceting() ? 1 : 0); + }, + + isConceptColumnFilter: function() { + return this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI && this.hasOntologyModule; + }, + + getDefaultView: function(filters) { + const xtypeVal = this.isConceptColumnFilter() + ? 'filter-view-conceptfilter' + : 'filter-view-default'; + + return { + xtype: xtypeVal, + column: this.column, + fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly + dataRegionName: this.dataRegionName, + jsonType : this.jsonType, + filters: filters + }; + }, + + // Override to return your own filter views + getViews : function() { + + const filters = this._getFilters(), views = []; + + // default view + views.push(this.getDefaultView(filters)); + + // facet view + if (this.allowFaceting()) { + views.push({ + xtype: 'filter-view-faceted', + column: this.column, + fieldKey: this.fieldKey, // should not have to hand this in bc the column should supply correctly + dataRegionName: this.dataRegionName, + jsonType : this.jsonType, + filters: filters, + cacheResults: this.cacheFacetResults, + listeners: { + invalidfacetedfilter : function() { + this.carryfilter = false; + this.getContainer().setActiveTab(0); + this.getContainer().getActiveTab().doLayout(); + this.carryfilter = true; + }, + scope: this + }, + scope: this + }) + } + + return views; + } +}); + +LABKEY.FilterDialog.ViewPanel = Ext.extend(Ext.form.FormPanel, { + + supportsMultipleFilters: false, + + filters : [], + + changed : false, + + initComponent : function() { + if (!this['dataRegionName']) { + console.error('dataRegionName is required for a LABKEY.FilterDialog.ViewPanel'); + return; + } + LABKEY.FilterDialog.ViewPanel.superclass.initComponent.call(this); + }, + + // Override to provide own view validation + checkValid : function() { + return true; + }, + + getDataRegion : function() { + return LABKEY.DataRegions[this.dataRegionName]; + }, + + getFilters : function() { + console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement getFilters()'); + }, + + setFilters : function(filterArray) { + console.error('All classes which extend LABKEY.FilterDialog.ViewPanel must implement setFilters(filterArray)'); + }, + + getXtypes : function() { + const textInputTypes = ['textfield', 'textarea']; + switch (this.jsonType) { + case "date": + return ["datefield"]; + case "int": + case "float": + return textInputTypes; + case "boolean": + return ['labkey-booleantextfield']; + default: + return textInputTypes; + } + }, + + // Returns true if a view has been altered since the last time it was activated + isChanged : function() { + return this.changed; + } +}); + +Ext.ns('LABKEY.FilterDialog.View'); + +LABKEY.FilterDialog.View.Default = Ext.extend(LABKEY.FilterDialog.ViewPanel, { + + supportsMultipleFilters: true, + + itemDefaults: { + border: false, + msgTarget: 'under' + }, + + initComponent : function() { + + Ext.apply(this, { + autoHeight: true, + title: this.title === false ? false : 'Choose Filters', + bodyStyle: 'padding: 5px;', + bubbleEvents: ['add', 'remove', 'clientvalidation'], + defaults: { border: false }, + items: this.generateFilterDisplays(2) + }); + + this.combos = []; + this.inputs = []; + + LABKEY.FilterDialog.View.Default.superclass.initComponent.call(this); + + this.on('activate', this.onViewReady, this, {single: true}); + }, + + updateViewReady: function(f) { + var filter = this.filters[f]; + var combo = this.combos[f]; + + // Update the input enabled/disabled status by using the 'select' event listener on the combobox. + // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. + var store = combo.getStore(); + var filterType = filter.getFilterType(); + var urlSuffix = filterType.getURLSuffix(); + if (store) { + var rec = store.getAt(store.find('value', urlSuffix)); + if (rec) { + combo.setValue(urlSuffix); + combo.fireEvent('select', combo, rec); + } + } + + var inputValue = filter.getURLParameterValue(); + + if (this.jsonType === "date" && inputValue) { + const dateVal = Date.parseDate(inputValue, LABKEY.extDateInputFormat); // date inputs are formatted to ISO date format on server + inputValue = dateVal.format(LABKEY.extDefaultDateFormat); // convert back to date field accepted format for render + } + + // replace multivalued separator (i.e. ;) with \n on UI + if (filterType.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { + var valueSeparator = filterType.getMultiValueSeparator(); + if (typeof inputValue === 'string' && inputValue.indexOf('\n') === -1 && inputValue.indexOf(valueSeparator) > 0) { + inputValue = filterType.parseValue(inputValue); + if (LABKEY.Utils.isArray(inputValue)) + inputValue = inputValue.join('\n'); + } + } + + var inputs = this.getVisibleInputs(); + if (inputs[f]) { + inputs[f].setValue(inputValue); + } + }, + + onViewReady : function() { + var inputs = this.getVisibleInputs(); + if (this.filters.length == 0) { + for (var c=0; c < this.combos.length; c++) { + // Update the input enabled/disabled status by using the 'select' event listener on the combobox. + // However, ComboBox doesn't fire 'select' event when changed programatically so we fire it manually. + this.combos[c].reset(); + this.combos[c].fireEvent('select', this.combos[c], null); + if (inputs[c]) { + inputs[c].reset(); + } + } + } + else { + for (var f=0; f < this.filters.length; f++) { + if (f < this.combos.length) { + this.updateViewReady(f); + } + } + } + + //Issue 24550: always select the first filter field, and also select text if present + if (inputs[0]) { + inputs[0].focus(true, 100, inputs[0]); + } + + this.changed = false; + }, + + getVisibleInputs: function() { + return this.inputs.filter(input => !input.hidden); + }, + + checkValid : function() { + var combos = this.combos; + var inputs = this.getVisibleInputs(), input, value, f; + + var isValid = true; + + Ext.each(combos, function(c, i) { + if (!c.isValid()) { + isValid = false; + } + else { + input = inputs[i]; + value = input.getValue(); + + f = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); + + if (!f) { + alert('filter not found: ' + c.getValue()); + return; + } + + if (f.isDataValueRequired() && Ext.isEmpty(value)) { + input.markInvalid('You must enter a value'); + isValid = false; + } + } + }); + + return isValid; + }, + + inputFieldValidator : function(input, combo) { + + var store = combo.getStore(); + if (store) { + var rec = store.getAt(store.find('value', combo.getValue())); + var filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); + + if (rec) { + if (filter.isMultiValued()) + return this.validateMultiValueInput(input.getValue(), filter.getMultiValueSeparator(), filter.getMultiValueMinOccurs(), filter.getMultiValueMaxOccurs()); + return this.validateInputField(input.getValue()); + } + } + return true; + }, + + addFilterConfig: function(idx, items) { + var subItems = [this.getComboConfig(idx)]; + var inputConfigs = this.getInputConfigs(idx); + inputConfigs.forEach(config => { + subItems.push(config); + }); + items.push({ + xtype: 'panel', + layout: 'form', + itemId: 'filterPair' + idx, + border: false, + defaults: this.itemDefaults, + items: subItems, + scope: this + }); + }, + + generateFilterDisplays : function(quantity) { + var idx = this.nextIndex(), items = [], i=0; + + for(; i < quantity; i++) { + this.addFilterConfig(idx, items); + + idx++; + } + + return items; + }, + + getDefaultFilterType: function(idx) { + return idx === 0 ? LABKEY.Filter.getDefaultFilterForType(this.jsonType).getURLSuffix() : ''; + }, + + getComboConfig : function(idx) { + var val = this.getDefaultFilterType(idx); + + return { + xtype: 'combo', + itemId: 'filterComboBox' + idx, + filterIndex: idx, + name: 'filterType_'+(idx + 1), //for compatibility with tests... + listWidth: (this.jsonType == 'date' || this.jsonType == 'boolean') ? null : 380, + emptyText: idx === 0 ? 'Choose a filter:' : 'No other filter', + autoSelect: false, + width: 330, + minListWidth: 330, + triggerAction: 'all', + fieldLabel: (idx === 0 ?'Filter Type' : 'and'), + store: this.getSelectionStore(idx), + displayField: 'text', + valueField: 'value', + typeAhead: 'false', + forceSelection: true, + mode: 'local', + clearFilterOnReset: false, + editable: false, + value: val, + originalValue: val, + listeners : { + render : function(combo) { + this.combos.push(combo); + // Update the associated inputField's enabled/disabled state on initial render + this.enableInputField(combo); + }, + select : function (combo) { + this.changed = true; + this.enableInputField(combo); + }, + scope: this + }, + scope: this + }; + }, + + enableInputField : function (combo) { + + var idx = combo.filterIndex; + var inputField = this.find('itemId', 'inputField'+idx+'-0')[0]; + var textAreaField = this.find('itemId', 'inputField'+idx+'-1')[0]; + + const urlSuffix = combo.getValue().toLowerCase(); + var filter = LABKEY.Filter.getFilterTypeForURLSuffix(urlSuffix); + var selectedValue = filter ? filter.getURLSuffix() : ''; + + var combos = this.combos; + var inputFields = this.inputs; + + if (filter && !filter.isDataValueRequired()) { + //Disable the field and allow it to be blank for values 'isblank' and 'isnonblank'. + inputField.disable(); + inputField.setValue(); + inputField.blur(); + if (textAreaField) + { + textAreaField.disable(); + textAreaField.setValue(); + textAreaField.blur(); + } + } + else { + if (filter.isMultiValued() && (urlSuffix !== 'notbetween' && urlSuffix !== 'between')) { + textAreaField.show(); + textAreaField.enable(); + textAreaField.setValue(inputField.getValue()); + textAreaField.validate(); + textAreaField.focus('', 50); + inputField.hide(); + } + else { + inputField.show(); + inputField.enable(); + inputField.setValue(textAreaField && textAreaField.getValue()); + inputField.validate(); + inputField.focus('', 50); + textAreaField && textAreaField.hide(); + } + } + + //if the value is null, this indicates no filter chosen. if it lacks an operator (ie. isBlank) + //in either case, this means we should disable all other filters + if(selectedValue == '' || !filter.isDataValueRequired()){ + //Disable all subsequent combos + Ext.each(combos, function(combo, idx) { + //we enable the next combo in the series + if(combo.filterIndex == this.filterIndex + 1){ + combo.setValue(); + inputFields[idx].setValue(); + inputFields[idx].enable(); + inputFields[idx].validate(); + inputFields[idx].blur(); + } + else if (combo.filterIndex > this.filterIndex){ + combo.setValue(); + inputFields[idx].disable(); + } + + }, this); + } + else{ + //enable the other filterComboBoxes. + Ext.each(combos, function(combo, i) { combo.enable(); }, this); + + if (combos.length) { + combos[0].focus('', 50); + } + } + }, + + getFilters : function() { + + var inputs = this.getVisibleInputs(); + var combos = this.combos; + var value, type, filters = []; + + Ext.each(combos, function(c, i) { + if (!inputs[i].disabled || (c.getRawValue() != 'No Other Filter')) { + value = inputs[i].getValue(); + type = LABKEY.Filter.getFilterTypeForURLSuffix(c.getValue()); + + if (!type) { + alert('Filter not found for suffix: ' + c.getValue()); + } + + // Issue 52068: for multivalued filter types, split on new line to get an array of values + if (value && type.isMultiValued()) { + value = value.indexOf('\n') > -1 ? value.split('\n') : type.parseValue(value); + } + + filters.push(LABKEY.Filter.create(this.fieldKey, value, type)); + } + }, this); + + return filters; + }, + + getAltDateFormats: function() { + if (this.jsonType === "date") + return 'Y-m-d|' + LABKEY.Utils.getDateAltFormats(); // always support ISO + return undefined; + }, + + getInputConfigs : function(idx) { + var me = this; + const xTypes = this.getXtypes(); + var configs = []; + xTypes.forEach((xType, typeId) => { + var config = { + xtype : xType, + itemId : 'inputField' + idx + '-' + typeId, + filterIndex : idx, + id : 'value_'+(idx + 1) + (typeId ? '-' + typeId: ''), //for compatibility with tests... + width : 330, + blankText : 'You must enter a value.', + validateOnBlur: true, + value : null, + altFormats : this.getAltDateFormats(), + hidden: typeId === 1, + disabled: typeId === 1, + emptyText: xType === 'textarea' ? 'Use new line or semicolon to separate entries' : (me.jsonType === 'time' ? 'HH:mm:ss' : undefined), + style: { resize: 'none' }, + validator : function(value) { + + // support for filtering '∞' + if (me.jsonType == 'float' && value.indexOf('∞') > -1) { + value = value.replace('∞', 'Infinity'); + this.setRawValue(value); // does not fire validation + } + + var combos = me.combos; + if (!combos.length) { + return; + } + + return me.inputFieldValidator(this, combos[idx]); + }, + listeners: { + disable : function(field){ + //Call validate after disable so any pre-existing validation errors go away. + if(field.rendered) { + field.validate(); + } + }, + focus : function(f) { + if (this.focusTask) { + Ext.TaskMgr.stop(this.focusTask); + } + }, + render : function(input) { + me.inputs.push(input); + if (!me.focusReady) { + me.focusReady = true; + // create a task to set the input focus that will get started after layout is complete, + // the task will run for a max of 2000ms but will get stopped when the component receives focus + this.focusTask = {interval:150, run: function(){ + input.focus(null, 50); + Ext.TaskMgr.stop(this.focusTask); + }, scope: this, duration: 2000}; + } + }, + change : this.inputListener, + scope : this + }, + scope: this + }; + if (this.jsonType === "date") { + config.format = LABKEY.extDefaultDateFormat; + + // default invalidText : "{0} is not a valid date - it must be in the format {1}", + // override the default warning msg as there is one preferred format, but there are also a set of acceptable altFormats + config.invalidText = "{0} might not be a valid date - the preferred format is {1}"; + } + + configs.push(config); + }) + return configs; + }, + + inputListener : function(input, newVal, oldVal) { + if (oldVal != newVal) { + this.changed = true; + } + }, + + getFilterTypes: function() { + return LABKEY.Filter.getFilterTypesForType(this.jsonType, this.column.mvEnabled); + }, + + getSelectionStore : function(storeNum) { + var fields = ['text', 'value', + {name: 'isMulti', type: Ext.data.Types.BOOL}, + {name: 'isOperatorOnly', type: Ext.data.Types.BOOL} + ]; + var store = new Ext.data.ArrayStore({ + fields: fields, + idIndex: 1 + }); + var comboRecord = Ext.data.Record.create(fields); + + var filters = this.getFilterTypes(); + + for (var i=0; i 0) { + store.removeAt(0); + store.insert(0, new comboRecord({text:'No Other Filter', value: ''})); + } + + return store; + }, + + setFilters : function(filterArray) { + this.filters = filterArray; + this.onViewReady(); + }, + + nextIndex : function() { + return 0; + }, + + validateMultiValueInput : function(inputValues, multiValueSeparator, minOccurs, maxOccurs) { + if (!inputValues) + return true; + // Used when "Equals One Of.." or "Between" is selected. Calls validateInputField on each value entered. + const sep = inputValues.indexOf('\n') > 0 ? '\n' : multiValueSeparator; + var values = inputValues.split(sep); + var isValid = ""; + for(var i = 0; i < values.length; i++){ + isValid = this.validateInputField(values[i]); + if(isValid !== true){ + return isValid; + } + } + + if (minOccurs !== undefined && minOccurs > 0) + { + if (values.length < minOccurs) + return "At least " + minOccurs + " '" + multiValueSeparator + "' separated values are required"; + } + + if (maxOccurs !== undefined && maxOccurs > 0) + { + if (values.length > maxOccurs) + return "At most " + maxOccurs + " '" + multiValueSeparator + "' separated values are allowed"; + } + + if (!Ext.isEmpty(inputValues) && typeof inputValues === 'string' && inputValues.trim().length > 2000) + return "Value is too long"; + + //If we make it out of the for loop we had no errors. + return true; + }, + + // The fact that Ext3 ties validation to the editor is a little funny, + // but using this shifts the work to Ext + validateInputField : function(value) { + var map = { + 'string': 'STRING', + 'time': 'STRING', + 'int': 'INT', + 'float': 'FLOAT', + 'date': 'DATE', + 'boolean': 'BOOL' + }; + var type = map[this.jsonType]; + if (type) { + var field = new Ext.data.Field({ + type: Ext.data.Types[type], + allowDecimals : this.jsonType != "int", //will be ignored by anything besides numberfield + useNull: true + }); + + var values = (!Ext.isEmpty(value) && typeof value === 'string' && value.indexOf('\n') > -1) ? value.split('\n') : [value]; + var invalid = null; + values.forEach(val => { + if (val == null) + return; + var convertedVal = field.convert(val); + if (!Ext.isEmpty(val) && val != convertedVal) { + invalid = val; + } + }) + + if (invalid != null) + return "Invalid value: " + invalid; + + if (!Ext.isEmpty(value) && typeof value === 'string' && value.trim().length > 2000) + return "Value is too long"; + } + else { + if (this.jsonType.toLowerCase() !== 'array') + console.log('Unrecognized type: ' + this.jsonType); + } + + return true; + } +}); + +Ext.reg('filter-view-default', LABKEY.FilterDialog.View.Default); + +LABKEY.FilterDialog.View.Faceted = Ext.extend(LABKEY.FilterDialog.ViewPanel, { + + MAX_FILTER_CHOICES: 250, // This is the maximum number of filters that will be requested / shown + + applyContextFilters: true, + + /** + * Logically convert filters to try and optimize the query on the server. + * (e.g. using NOT IN when less than half the available values are checked) + */ + filterOptimization: true, + + cacheResults: true, + + emptyDisplayValue: '[Blank]', + + gridID: Ext.id(), + + loadError: undefined, + + overflow: false, + + initComponent : function() { + + Ext.apply(this, { + title : 'Choose Values', + border : false, + height : 200, + bodyStyle: 'overflow-x: hidden; overflow-y: auto', + bubbleEvents: ['add', 'remove', 'clientvalidation'], + defaults : { + border : false + }, + markDisabled : true, + items: [{ + layout: 'hbox', + style: 'padding-bottom: 5px; overflow-x: hidden', + defaults: { + border: false + }, + items: [{ + xtype: 'box', + cls: 'alert alert-danger', + hidden: true, + id: this.gridID + '-error', + style: 'position: relative;', + },{ + xtype: 'label', + id: this.gridID + 'OverflowLabel', + hidden: true, + text: 'There are more than ' + this.MAX_FILTER_CHOICES + ' values. Showing a partial list.' + }] + }] + }); + + LABKEY.FilterDialog.View.Faceted.superclass.initComponent.call(this); + + this.on('render', this.onPanelRender, this, {single: true}); + }, + + formatValue : function(val) { + if(this.column) { + if (this.column.extFormatFn) { + try { + this.column.extFormatFn = eval(this.column.extFormatFn); + } + catch (error) { + console.log('improper extFormatFn: ' + this.column.extFormatFn); + } + + if (Ext.isFunction(this.column.extFormatFn)) { + val = this.column.extFormatFn(val); + } + } + else if (this.jsonType == 'int') { + val = parseInt(val); + } + } + return val; + }, + + // copied from Ext 4 Ext.Array.difference + difference : function(arrayA, arrayB) { + var clone = arrayA.slice(), + ln = clone.length, + i, j, lnB; + + for (i = 0,lnB = arrayB.length; i < lnB; i++) { + for (j = 0; j < ln; j++) { + if (clone[j] === arrayB[i]) { + clone.splice(j, 1); + j--; + ln--; + } + } + } + + return clone; + }, + + constructFilter : function(selected, unselected) { + var filter = null; + + if (selected.length > 0) { + + var columnName = this.fieldKey; + + // one selection + if (selected.length == 1) { + if (selected[0].get('displayValue') == this.emptyDisplayValue) + filter = LABKEY.Filter.create(columnName, null, LABKEY.Filter.Types.ISBLANK); + else + filter = LABKEY.Filter.create(columnName, selected[0].get('value')); // default EQUAL + } + else if (this.filterOptimization && selected.length > unselected.length) { + // Do the negation + if (unselected.length == 1) { + var val = unselected[0].get('value'); + var type = (val === "" ? LABKEY.Filter.Types.NONBLANK : LABKEY.Filter.Types.NOT_EQUAL_OR_MISSING); + + // 18716: Check if 'unselected' contains empty value + filter = LABKEY.Filter.create(columnName, val, type); + } + else + filter = LABKEY.Filter.create(columnName, this.selectedToValues(unselected), LABKEY.Filter.Types.NOT_IN); + } + else { + filter = LABKEY.Filter.create(columnName, this.selectedToValues(selected), LABKEY.Filter.Types.IN); + } + } + + return filter; + }, + + // get array of values from the selected store item array + selectedToValues : function(valueArray) { + return valueArray.map(function (i) { return i.get('value'); }); + }, + + // Implement interface LABKEY.FilterDialog.ViewPanel + getFilters : function() { + var grid = Ext.getCmp(this.gridID); + var filters = []; + + if (grid) { + var store = grid.store; + var count = store.getCount(); // TODO: Check if store loaded + var selected = grid.getSelectionModel().getSelections(); + + if (count == 0 || selected.length == 0 || selected.length == count) { + filters = []; + } + else { + var unselected = this.filterOptimization ? this.difference(store.getRange(), selected) : []; + filters = [this.constructFilter(selected, unselected)]; + } + } + + return filters; + }, + + // Implement interface LABKEY.FilterDialog.ViewPanel + setFilters : function(filterArray) { + if (Ext.isArray(filterArray)) { + this.filters = filterArray; + this.onViewReady(); + } + }, + + getGridConfig : function(idx) { + var sm = new Ext.grid.CheckboxSelectionModel({ + listeners: { + selectionchange: { + fn: function(sm) { + // NOTE: this will manually set the checked state of the header checkbox. it would be better + // to make this a real tri-state (ie. selecting some records is different then none), but since this is still Ext3 + // and ext4 will be quite different it doesnt seem worth the effort right now + var selections = sm.getSelections(); + var headerCell = Ext.fly(sm.grid.getView().getHeaderCell(0)).first('div'); + if(selections.length == sm.grid.store.getCount()){ + headerCell.addClass('x-grid3-hd-checker-on'); + } + else { + headerCell.removeClass('x-grid3-hd-checker-on'); + } + + + }, + buffer: 50 + } + } + }); + + var me = this; + + return { + xtype: 'grid', + id: this.gridID, + border: true, + bodyBorder: true, + frame: false, + autoHeight: true, + itemId: 'inputField' + (idx || 0), + filterIndex: idx || 0, + msgTarget: 'title', + store: this.getLookupStore(), + headerClick: false, + viewConfig: { + headerTpl: new Ext.Template( + '', + '', + '{cells}', + '', + '
' + ) + }, + sm: sm, + cls: 'x-grid-noborder', + columns: [ + sm, + new Ext.grid.TemplateColumn({ + header: '[All]', + dataIndex: 'value', + menuDisabled: true, + resizable: false, + width: 340, + tpl: new Ext.XTemplate('' + + '' + + '{[Ext.util.Format.htmlEncode(values["displayValue"])]}' + + '') + }) + ], + listeners: { + afterrender : function(grid) { + grid.getSelectionModel().on('selectionchange', function() { + this.changed = true; + }, this); + + grid.on('viewready', function(g) { + this.gridReady = true; + this.onViewReady(); + }, this, {single: true}); + }, + scope : this + }, + // extend toggle behavior to the header cell, not just the checkbox next to it + onHeaderCellClick : function() { + var sm = this.getSelectionModel(); + var selected = sm.getSelections(); + selected.length == this.store.getCount() ? this.selectNone() : this.selectAll(); + }, + getValue : function() { + var vals = this.getValues(); + if (vals.length == vals.max) { + return []; + } + return vals.values; + }, + getValues : function() { + var values = [], + sels = this.getSelectionModel().getSelections(); + + Ext.each(sels, function(rec){ + values.push(rec.get('strValue')); + }, this); + + if(values.indexOf('') != -1 && values.length == 1) + values.push(''); //account for null-only filtering + + return { + values : values.join(';'), + length : values.length, + max : this.getStore().getCount() + }; + }, + setValue : function(values, negated) { + if (!this.rendered) { + this.on('render', function() { + this.setValue(values, negated); + }, this, {single: true}); + } + + if (!Ext.isArray(values)) { + values = values.split(';'); + } + + if (this.store.isLoading) { + // need to wait for the store to load to ensure records + this.store.on('load', function() { + this._checkAndLoadValues(values, negated); + }, this, {single: true}); + } + else { + this._checkAndLoadValues(values, negated); + } + }, + _checkAndLoadValues : function(values, negated) { + var records = [], + recIdx, + recordNotFound = false; + + Ext.each(values, function(val) { + recIdx = this.store.findBy(function(rec){ + return rec.get('strValue') === val; + }); + + if (recIdx != -1) { + records.push(recIdx); + } + else { + // Issue 14710: if the record isnt found, we wont be able to select it, so should reject. + // If it's null/empty, ignore silently + if (!Ext.isEmpty(val)) { + recordNotFound = true; + return false; + } + } + }, this); + + if (negated) { + var count = this.store.getCount(), found = false, negRecords = []; + for (var i=0; i < count; i++) { + found = false; + for (var j=0; j < records.length; j++) { + if (records[j] == i) + found = true; + } + if (!found) { + negRecords.push(i); + } + } + records = negRecords; + } + + if (recordNotFound) { + // cannot find any matching records + if (me.column.facetingBehaviorType != 'ALWAYS_ON') + me.fireEvent('invalidfacetedfilter'); + return; + } + + this.getSelectionModel().selectRows(records); + }, + selectAll : function() { + if (this.rendered) { + var sm = this.getSelectionModel(); + sm.selectAll.defer(10, sm); + } + else { + this.on('render', this.selectAll, this, {single: true}); + } + }, + selectNone : function() { + if (this.rendered) { + this.getSelectionModel().selectRows([]); + } + else { + this.on('render', this.selectNone, this, {single: true}); + } + }, + determineNegation: function(filter) { + var suffix = filter.getFilterType().getURLSuffix(); + var negated = suffix == 'neqornull' || suffix == 'notin'; + + // negation of the null case is a bit different so check it as a special case. + var value = filter.getURLParameterValue(); + if (value == "" && suffix != 'isblank') { + negated = true; + } + return negated; + }, + selectFilter : function(filter) { + var negated = this.determineNegation(filter); + + this.setValue(filter.getURLParameterValue(), negated); + + if (!me.filterOptimization && negated) { + me.fireEvent('invalidfacetedfilter'); + } + }, + scope : this + }; + }, + + shouldShowFaceted : function(filter) { + const CHOOSE_VALUE_FILTERS = [ + LABKEY.Filter.Types.EQUAL.getURLSuffix(), + LABKEY.Filter.Types.IN.getURLSuffix(), + LABKEY.Filter.Types.NEQ.getURLSuffix(), + LABKEY.Filter.Types.NEQ_OR_NULL.getURLSuffix(), + LABKEY.Filter.Types.NOT_IN.getURLSuffix(), + ]; + + if (!filter) + return true; + + return CHOOSE_VALUE_FILTERS.indexOf(filter.getFilterType().getURLSuffix()) >= 0; + }, + + onViewReady : function() { + if (this.gridReady && this.storeReady) { + var grid = Ext.getCmp(this.gridID); + this.hideMask(); + + if (grid) { + + var numFilters = this.filters.length; + var numFacets = grid.store.getCount(); + + // apply current filter + if (numFacets == 0) + grid.selectNone(); + else if (numFilters == 0) + grid.selectAll(); + else + grid.selectFilter(this.filters[0]); + + // Issue 52547: LKS filter dialog treats many filter types as if they are Equals + if (numFilters > 1 || !this.shouldShowFaceted(this.filters[0])) + this.fireEvent('invalidfacetedfilter'); + + if (!grid.headerClick) { + grid.headerClick = true; + var div = Ext.fly(grid.getView().getHeaderCell(1)).first('div'); + div.on('click', grid.onHeaderCellClick, grid); + } + + if (this.loadError) { + var errorCmp = Ext.getCmp(this.gridID + '-error'); + errorCmp.update(this.loadError); + errorCmp.setVisible(true); + } + + // Issue 39727 - show a message if we've capped the number of options shown + Ext.getCmp(this.gridID + 'OverflowLabel').setVisible(this.overflow); + + if (this.loadError || this.overflow) { + this.fireEvent('invalidfacetedfilter'); + } + } + } + + this.changed = false; + }, + + getLookupStore : function() { + var dr = this.getDataRegion(); + var storeId = this.cacheResults ? [dr.schemaName, dr.queryName, this.fieldKey].join('||') : Ext.id(); + + // cache + var store = Ext.StoreMgr.get(storeId); + if (store) { + this.storeReady = true; // unsafe + return store; + } + + store = new Ext.data.ArrayStore({ + fields : ['value', 'strValue', 'displayValue'], + storeId: storeId + }); + + var config = { + schemaName: dr.schemaName, + queryName: dr.queryName, + dataRegionName: dr.name, + viewName: dr.viewName, + column: this.fieldKey, + filterArray: dr.filters, + containerPath: dr.container || dr.containerPath || LABKEY.container.path, + containerFilter: dr.getContainerFilter(), + parameters: dr.getParameters(), + maxRows: this.MAX_FILTER_CHOICES+1, + ignoreFilter: dr.ignoreFilter, + success : function(d) { + if (d && d.values) { + var recs = [], v, i=0, hasBlank = false, isString, formattedValue; + + // Issue 39727 - remember if we exceeded our cap so we can show a message + this.overflow = d.values.length > this.MAX_FILTER_CHOICES; + + for (; i < Math.min(d.values.length, this.MAX_FILTER_CHOICES); i++) { + v = d.values[i]; + formattedValue = this.formatValue(v); + isString = Ext.isString(formattedValue); + + if (formattedValue == null || (isString && formattedValue.length == 0) || (!isString && isNaN(formattedValue))) { + hasBlank = true; + } + else if (Ext.isDefined(v)) { + recs.push([v, v.toString(), v.toString()]); + } + } + + if (hasBlank) + recs.unshift(['', '', this.emptyDisplayValue]); + + store.loadData(recs); + store.isLoading = false; + this.storeReady = true; + this.onViewReady(); + } + }, + failure: function(err) { + if (err && err.exception) { + this.loadError = err.exception; + } else { + this.loadError = 'Failed to load faceted data.'; + } + store.isLoading = false; + this.storeReady = true; + this.onViewReady(); + }, + scope: this + }; + + if (this.applyContextFilters) { + var userFilters = dr.getUserFilterArray(); + if (userFilters && userFilters.length > 0) { + + var uf = []; + + // Remove filters for the current column + for (var i=0; i < userFilters.length; i++) { + if (userFilters[i].getColumnName() != this.fieldKey) { + uf.push(userFilters[i]); + } + } + + config.filterArray = uf; + } + } + + // Use Select Distinct + LABKEY.Query.selectDistinctRows(config); + + return Ext.StoreMgr.add(store); + }, + + onPanelRender : function(panel) { + var toAdd = [{ + xtype: 'panel', + width: this.width - 40, //prevent horizontal scroll + bodyStyle: 'padding-left: 5px;', + items: [ this.getGridConfig(0) ], + listeners : { + afterrender : { + fn: this.showMask, + scope: this, + single: true + } + } + }]; + panel.add(toAdd); + }, + + showMask : function() { + if (!this.gridReady && this.getEl()) { + this.getEl().mask('Loading...'); + } + }, + + hideMask : function() { + if (this.getEl()) { + this.getEl().unmask(); + } + } +}); + +Ext.reg('filter-view-faceted', LABKEY.FilterDialog.View.Faceted); + +LABKEY.FilterDialog.View.ConceptFilter = Ext.extend(LABKEY.FilterDialog.View.Default, { + + initComponent: function () { + this.updateConceptFilters = []; + + LABKEY.FilterDialog.View.ConceptFilter.superclass.initComponent.call(this); + }, + + getListenerConfig: function(index) { + if (!this.updateConceptFilters[index]) { + this.updateConceptFilters[index] = {filterIndex: index}; + } + + return this.updateConceptFilters[index]; + }, + + //Callback from RequireScripts is passed a contextual this object + loadConceptPickers: function() { + const ctx = this; + const divId = ctx.divId, + index = ctx.index, + scope = ctx.scope; + + LABKEY.App.loadApp('conceptFilter', divId, { + ontologyId: scope.column.sourceOntology, + conceptSubtree: scope.column.conceptSubtree, + columnName: scope.column.caption, + onFilterChange: function(filterValue) { + // Inputs may be set after app load, so look it up at execution time + const inputs = scope.inputs; + if (!inputs) + return; + + const textInput = inputs[index * 2]; // one text input, one textarea input + const textAreaInput = inputs[index * 2 + 1]; + const targetInput = textInput && !textInput.hidden ? textInput: textAreaInput; + + // push values selected in tree to the target input control + if (targetInput && !targetInput.disabled) { + targetInput.setValue(filterValue); + targetInput.validate(); + } + }, + subscribeFilterValue: function(listener) { + scope.getListenerConfig(index).setValue = listener; + this.changed = true; + }, + unsubscribeFilterValue: function() { + scope.getListenerConfig(index).setValue = undefined; + }, + subscribeFilterTypeChanged: function(listener) { + scope.getListenerConfig(index).setFilterType = listener; + this.changed = true; + }, + unsubscribeFilterTypeChanged: function() { + scope.getListenerConfig(index).setFilterType = undefined; + }, + loadListener: function() { + scope.onViewReady(); // TODO be a little more targeted, but this ensures the filtertype & filterValue parameters get set because the Ext elements get rendered & set async + }, + subscribeCollapse: function(listener) { + scope.getListenerConfig(index).collapsePanel = listener; + }, + unsubscribeCollapse: function() { + scope.getListenerConfig(index).collapsePanel = undefined; + }, + onOpen: function() { + scope.updateConceptFilters.forEach( function(panel) { + if (panel.filterIndex !== index) panel.collapsePanel(); + }); + } + }); + }, + + addFilterConfig: function(idx, items) { + LABKEY.FilterDialog.View.ConceptFilter.superclass.addFilterConfig.call(this, idx, items); + + const divId = LABKEY.Utils.generateUUID(); + items.push( this.getConceptBrowser(idx, divId)); + }, + + getConceptBrowser: function (idx, divId) { + if (this.column.conceptURI === CONCEPT_CODE_CONCEPT_URI) { + const index = idx; + return { + xtype: 'panel', + layout: 'form', + id: divId, + border: false, + defaults: this.itemDefaults, + items: [{ + value: 'a', + scope: this + }], + listeners: { + render: function() { + // const conceptFilterScript = 'http://localhost:3001/conceptFilter.js'; + const conceptFilterScript = 'gen/conceptFilter'; + LABKEY.requiresScript(conceptFilterScript, this.loadConceptPickers, {divId:divId, index:index, scope:this}); + }, + scope: this + }, + scope: this + }; + } + }, + + getDefaultFilterType: function(idx) { + //Override the default for Concepts unless it is blank + return idx === 0 ? LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE.getURLSuffix() : ''; + }, + + getFilterTypes: function() { + return [ + LABKEY.Filter.Types.HAS_ANY_VALUE, + LABKEY.Filter.Types.EQUAL, + LABKEY.Filter.Types.NEQ_OR_NULL, + LABKEY.Filter.Types.ISBLANK, + LABKEY.Filter.Types.NONBLANK, + LABKEY.Filter.Types.IN, + LABKEY.Filter.Types.NOT_IN, + LABKEY.Filter.Types.ONTOLOGY_IN_SUBTREE, + LABKEY.Filter.Types.ONTOLOGY_NOT_IN_SUBTREE + ]; + }, + + enableInputField: function(combo) { + LABKEY.FilterDialog.View.ConceptFilter.superclass.enableInputField.call(this, combo); + + const idx = combo.filterIndex; + const filter = LABKEY.Filter.getFilterTypeForURLSuffix(combo.getValue()); + if (this.updateConceptFilters) { + const updater = this.updateConceptFilters[idx]; + if (updater) { + updater.setFilterType(filter); + } + } + }, + + inputListener : function(input, newVal, oldVal) { + const idx = input.filterIndex; + if (oldVal != newVal) { + this.changed = true; + + const updater = this.updateConceptFilters[idx]; + if (updater) { + updater.setValue(newVal); + } + } + }, + + updateViewReady: function(f) { + LABKEY.FilterDialog.View.ConceptFilter.superclass.updateViewReady.call(this, f); + + // Update concept filters if possible + if (this.updateConceptFilters[f]) { + const filter = this.filters[f]; + const conceptBrowserUpdater = this.updateConceptFilters[f]; + + conceptBrowserUpdater.setValue(filter.getURLParameterValue()); + conceptBrowserUpdater.setFilterType(filter.getFilterType()); + } + } +}); + +Ext.reg('filter-view-conceptfilter', LABKEY.FilterDialog.View.ConceptFilter); + +Ext.ns('LABKEY.ext'); + +LABKEY.ext.BooleanTextField = Ext.extend(Ext.form.TextField, { + initComponent : function() { + Ext.apply(this, { + validator: function(val){ + if(!val) + return true; + + return LABKEY.Utils.isBoolean(val) ? true : val + " is not a valid boolean. Try true/false; yes/no; on/off; or 1/0."; + } + }); + LABKEY.ext.BooleanTextField.superclass.initComponent.call(this); + } +}); + +Ext.reg('labkey-booleantextfield', LABKEY.ext.BooleanTextField); From 987fc0160d458989612ebd123731a0b4570b1426 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 25 Feb 2026 14:30:22 -0800 Subject: [PATCH 3/4] Selenium tests --- api/src/org/labkey/api/data/JsonWriter.java | 3 ++- api/webapp/clientapi/ext3/FilterDialog.js | 3 --- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/api/src/org/labkey/api/data/JsonWriter.java b/api/src/org/labkey/api/data/JsonWriter.java index 4280839b1fb..76efd639af8 100644 --- a/api/src/org/labkey/api/data/JsonWriter.java +++ b/api/src/org/labkey/api/data/JsonWriter.java @@ -251,11 +251,12 @@ else if (cinfo.getJdbcType().isNumeric()) props.put("shortCaption", cinfo.getShortLabel()); - if (dc instanceof IMultiValuedDisplayColumn || (cinfo.getParentTable() != null && cinfo.getParentTable().getSqlDialect() != null && !cinfo.getParentTable().getSqlDialect().isSortableDataType(cinfo.getSqlTypeName()))) + if (dc instanceof IMultiValuedDisplayColumn || dc instanceof AbstractFileDisplayColumn || (cinfo.getParentTable() != null && cinfo.getParentTable().getSqlDialect() != null && !cinfo.getParentTable().getSqlDialect().isSortableDataType(cinfo.getSqlTypeName()))) { // Disallow faceted filtering when the column is multi-valued, as the value that comes out of the // database likely has a different delimiter compared to what the user wants to see and therefore // doesn't work very well. + // Disallow faceted filtering for file columns since the values are often absolute file path // Similarly, SQLServer doesn't allow doing a SELECT DISTINCT on TEXT columns, so check the data type (they also can't be sorted) props.put("facetingBehaviorType", FacetingBehaviorType.ALWAYS_OFF); diff --git a/api/webapp/clientapi/ext3/FilterDialog.js b/api/webapp/clientapi/ext3/FilterDialog.js index 10190638151..04bf945459f 100644 --- a/api/webapp/clientapi/ext3/FilterDialog.js +++ b/api/webapp/clientapi/ext3/FilterDialog.js @@ -101,9 +101,6 @@ LABKEY.FilterDialog = Ext.extend(Ext.Window, { } this.allowFacet = false; - if (this.column.inputType === 'file') - return this.allowFacet; - switch (this.column.facetingBehaviorType) { case 'ALWAYS_ON': From 83c00d77d06be193798da46188b76c76212c5ed8 Mon Sep 17 00:00:00 2001 From: XingY Date: Wed, 25 Feb 2026 16:52:21 -0800 Subject: [PATCH 4/4] Allow faceted filter for attachments --- api/src/org/labkey/api/data/JsonWriter.java | 6 +++--- .../labkey/api/study/assay/FileLinkDisplayColumn.java | 11 ----------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/api/src/org/labkey/api/data/JsonWriter.java b/api/src/org/labkey/api/data/JsonWriter.java index 76efd639af8..be24261e652 100644 --- a/api/src/org/labkey/api/data/JsonWriter.java +++ b/api/src/org/labkey/api/data/JsonWriter.java @@ -251,13 +251,13 @@ else if (cinfo.getJdbcType().isNumeric()) props.put("shortCaption", cinfo.getShortLabel()); - if (dc instanceof IMultiValuedDisplayColumn || dc instanceof AbstractFileDisplayColumn || (cinfo.getParentTable() != null && cinfo.getParentTable().getSqlDialect() != null && !cinfo.getParentTable().getSqlDialect().isSortableDataType(cinfo.getSqlTypeName()))) + if (PropertyType.FILE_LINK == cinfo.getPropertyType() || dc instanceof IMultiValuedDisplayColumn || (cinfo.getParentTable() != null && cinfo.getParentTable().getSqlDialect() != null && !cinfo.getParentTable().getSqlDialect().isSortableDataType(cinfo.getSqlTypeName()))) { + // Disallow faceted filtering for file columns since the values are often absolute file path + // Disallow faceted filtering when the column is multi-valued, as the value that comes out of the // database likely has a different delimiter compared to what the user wants to see and therefore // doesn't work very well. - // Disallow faceted filtering for file columns since the values are often absolute file path - // Similarly, SQLServer doesn't allow doing a SELECT DISTINCT on TEXT columns, so check the data type (they also can't be sorted) props.put("facetingBehaviorType", FacetingBehaviorType.ALWAYS_OFF); } diff --git a/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java b/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java index 77a147d72b3..bccdb82539c 100644 --- a/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java +++ b/api/src/org/labkey/api/study/assay/FileLinkDisplayColumn.java @@ -463,15 +463,4 @@ public Object getExportCompatibleValue(RenderContext ctx) return getJsonValue(ctx); } - @Override - public boolean isFilterable() - { - return true; - } - @Override - public boolean isSortable() - { - return true; - } - }