diff --git a/bin/test.sh b/bin/test.sh index df6ae1fe8..ac5c13de7 100755 --- a/bin/test.sh +++ b/bin/test.sh @@ -28,7 +28,9 @@ then echo "djlint --check ran successfully" else echo "There was an error running djlint --check" - uv run djlint --reformat "$target" + if uv run djlint --reformat "$target"; then + echo "djlint reformatted" + fi if ! uv run djlint --check "$target"; then echo "Couldn't fix with djlint --reformat" exit 1 diff --git a/docs/styleguide.md b/docs/styleguide.md index 767b5079a..237e9448b 100644 --- a/docs/styleguide.md +++ b/docs/styleguide.md @@ -307,6 +307,42 @@ def log_in_first(request: AuthenticatedHttpRequest) -> HttpResponse: ... - To style templates, Projectify uses [Tailwind version 3](https://v3.tailwindcss.com/). Refer to the tailwind configuration at `tailwind.config.js`. +### Partial defs + +Use Django `partialdef` like so: + +```html +{% partialdef button %} + +{% endpartialdef %} + +{% partial button %} +``` + +With `context={"name": "world"}`, this becomes the following: + +```html + +``` + +Pass arguments with `{% with %}`: + +```html +{% partialdef button %} + +{% endpartialdef %} + +{% with name="foobar" %} +{% partial button %} +{% endwith +``` + +This becomes the following: + +```html + +``` + ## Template includes ### Submit button diff --git a/projectify/blog/templates/blog/blog_base.html b/projectify/blog/templates/blog/blog_base.html index f25c509c9..06e4dde4a 100644 --- a/projectify/blog/templates/blog/blog_base.html +++ b/projectify/blog/templates/blog/blog_base.html @@ -5,9 +5,7 @@ {% extends "base.html" %} {% block body %}
{self.fake.paragraph()}
" + for _ in range(randint(2, 6)) + ), + ) + for workspace in workspaces + for _ in range(self.n_wiki_pages) + ] + wiki_pages = WikiPage.objects.bulk_create(wiki_page_descs) + self.stdout.write(f"Created {len(wiki_pages)} wiki pages") + def create_blog_posts(self) -> None: """Create blog posts.""" existing_posts = Post.objects.count() @@ -356,6 +380,12 @@ def add_arguments(self, parser: ArgumentParser) -> None: default=40, help="Ensure N blog posts are present", ) + parser.add_argument( + "--n-wiki-pages", + type=int, + default=5, + help="Ensure N wiki pages are added to each new workspace", + ) def handle(self, *args: object, **options: Any) -> None: """Handle.""" @@ -371,6 +401,7 @@ def handle(self, *args: object, **options: Any) -> None: self.n_tasks = options["n_tasks"] self.n_add_users = options["n_add_users"] self.n_posts = options["n_posts"] + self.n_wiki_pages = options["n_wiki_pages"] if self.n_add_users > self.n_users: self.stdout.write( f"You are trying to add more users to each workspace " @@ -391,4 +422,5 @@ def handle(self, *args: object, **options: Any) -> None: self.create_corporate_accounts( seats=self.n_users, workspaces=workspaces ) + self.create_wiki_pages(workspaces) self.create_blog_posts() diff --git a/projectify/onboarding/tests/test_views.py b/projectify/onboarding/tests/test_views.py index cac138c4c..2dae287c1 100644 --- a/projectify/onboarding/tests/test_views.py +++ b/projectify/onboarding/tests/test_views.py @@ -54,7 +54,7 @@ def test_post_about_you( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test setting a name.""" - with django_assert_num_queries(10): + with django_assert_num_queries(14): assert ( user_client.post( resource_url, {"preferred_name": "Test User"} @@ -91,7 +91,7 @@ def test_workspace_creation( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Create a new workspace.""" - with django_assert_num_queries(10): + with django_assert_num_queries(21): assert ( user_client.post( resource_url, {"title": "Woof woof"} @@ -138,7 +138,7 @@ def test_project_creation( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Test creating a new project.""" - with django_assert_num_queries(11): + with django_assert_num_queries(13): response = user_client.post(resource_url, {"title": "BarFoo"}) assert response.status_code == 302 project = Project.objects.get(title="BarFoo") @@ -191,7 +191,7 @@ def test_post_new_task( django_assert_num_queries: DjangoAssertNumQueries, ) -> None: """Create a new task.""" - with django_assert_num_queries(12): + with django_assert_num_queries(16): assert ( user_client.post( resource_url, {"title": "Test Task"} diff --git a/projectify/premail/email.py b/projectify/premail/email.py index 5e118366c..ecf9312d1 100644 --- a/projectify/premail/email.py +++ b/projectify/premail/email.py @@ -9,7 +9,6 @@ from django.template import loader from django.utils.safestring import SafeText, mark_safe -from projectify.context_processors import frontend_url from projectify.user.models import User Context = dict[str, Any] @@ -41,11 +40,7 @@ def get_body_template_path(self) -> str: def get_context(self) -> Context: """Get context. To override.""" - return { - **frontend_url(None), - "object": self.obj, - "addressee": self.addressee, - } + return {"object": self.obj, "addressee": self.addressee} def render_subject(self) -> SafeText: """Render subject.""" diff --git a/projectify/rules.py b/projectify/rules.py index 6c0bb8fd1..2bb2d609a 100644 --- a/projectify/rules.py +++ b/projectify/rules.py @@ -103,6 +103,9 @@ def can_create_more( ) # Return True if a team member invite can be sent for a workspace within_team_member_invite_quota = within_team_member_quota +within_attachment_quota = rules.predicate( + partial(can_create_more, "Attachment") +) # Workspace @@ -112,6 +115,12 @@ def can_create_more( rules.add_perm("workspace.update_workspace", is_at_least_owner) rules.add_perm("workspace.delete_workspace", is_at_least_owner) +# Wiki pages +rules.add_perm("workspace.create_wiki_page", is_at_least_contributor) +rules.add_perm("workspace.read_wiki_page", is_at_least_observer) +rules.add_perm("workspace.update_wiki_page", is_at_least_contributor) +rules.add_perm("workspace.delete_wiki_page", is_at_least_maintainer) + # Team member invite rules.add_perm( "workspace.create_team_member_invite", @@ -160,6 +169,15 @@ def can_edit_team_member(user: User, team_member: TeamMember) -> bool: rules.add_perm("workspace.update_task", is_at_least_contributor) rules.add_perm("workspace.delete_task", is_at_least_maintainer) +# Attachments +rules.add_perm( + "workspace.create_attachment", + is_at_least_contributor & within_attachment_quota, +) +rules.add_perm("workspace.read_attachment", is_at_least_observer) +rules.add_perm("workspace.update_attachment", is_at_least_contributor) +rules.add_perm("workspace.delete_attachment", is_at_least_maintainer) + # Customer rules.add_perm("corporate.can_create_customer", is_at_least_owner) rules.add_perm("corporate.can_read_customer", is_at_least_owner) diff --git a/projectify/settings/base.py b/projectify/settings/base.py index 535fe3730..d43303fed 100644 --- a/projectify/settings/base.py +++ b/projectify/settings/base.py @@ -29,6 +29,7 @@ from .monkeypatch import patch from .types import ( + FeatureFlags, SocialAccountProvider, StoragesConfig, StripeConfig, @@ -64,6 +65,12 @@ class Base(Configuration): See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ """ + # Feature flags + # ============= + # This tracks which features Projectify should activate + # For development -related features, see "Debug flags" below + FEATURE_FLAGS = FeatureFlags(workspace_attachments=False) + SECRET_KEY: str # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -75,7 +82,8 @@ class Base(Configuration): # SECURITY WARNING: don't run with debug turned on in production! ALLOWED_HOSTS: Sequence[str] = [] - # Debug + # Debug flags + # =========== # Should Django run in debug mode? DEBUG = False # Should Projectify render the Django debug toolbar? @@ -348,9 +356,10 @@ class Base(Configuration): "APP_DIRS": True, "OPTIONS": { "context_processors": ( - "projectify.context_processors.frontend_url", # For header nav "Go to dashboard" link "projectify.context_processors.show_go_to_dashboard", + # Pass state of feature flags into frontend + "projectify.context_processors.feature_flags", "django.template.context_processors.csp", "django.template.context_processors.debug", # `allauth` needs this from django diff --git a/projectify/settings/development.py b/projectify/settings/development.py index 2bf3c1c60..690643a3f 100644 --- a/projectify/settings/development.py +++ b/projectify/settings/development.py @@ -9,7 +9,7 @@ import dj_database_url -from .types import StripeConfig +from .types import FeatureFlags, StripeConfig try: from dotenv import load_dotenv @@ -44,6 +44,10 @@ def add_dev_middleware( class Development(Base): """Development configuration.""" + FEATURE_FLAGS = FeatureFlags( + workspace_attachments=True, workspace_wikis=True + ) + SITE_TITLE = "Local Development" SECRET_KEY = "development" diff --git a/projectify/settings/test.py b/projectify/settings/test.py index f63804530..2a49ec6fe 100644 --- a/projectify/settings/test.py +++ b/projectify/settings/test.py @@ -10,7 +10,7 @@ from faker import Faker -from projectify.settings.types import StripeConfig +from .types import FeatureFlags, StripeConfig try: from dotenv import load_dotenv @@ -27,6 +27,10 @@ class Test(Base): """Test configuration.""" + FEATURE_FLAGS = FeatureFlags( + workspace_attachments=True, workspace_wikis=True + ) + SITE_TITLE = "Projectify Pytest" MIDDLEWARE = [ diff --git a/projectify/settings/types.py b/projectify/settings/types.py index 40ace3261..6ae4e1557 100644 --- a/projectify/settings/types.py +++ b/projectify/settings/types.py @@ -51,6 +51,8 @@ class StorageConfig(TypedDict): ) +# TODO +# @dataclass(freeze=True, kw_only=True) @dataclass class StripeConfig: """Hold configuration needed to use Stripe.""" @@ -58,6 +60,20 @@ class StripeConfig: # XXX it doesn't look like Projectify is using the publishable key # TODO consider removing the STRIPE_PUBLISHABLE_KEY STRIPE_PUBLISHABLE_KEY: str + # TODO lower case the following three fields + # stripe_secret_key: str STRIPE_SECRET_KEY: str + # stripe_endpoint_secret: str STRIPE_ENDPOINT_SECRET: str + # stripe_price_object: str STRIPE_PRICE_OBJECT: str + + +@dataclass(frozen=True, kw_only=True) +class FeatureFlags: + """Projectify feature flags.""" + + """Set to True to Enable workspace attachments.""" + workspace_attachments: bool = False + """Set to True to Enable workspace Wikis.""" + workspace_wikis: bool = False diff --git a/projectify/static/css/dist/styles.css b/projectify/static/css/dist/styles.css index b9c4fceef..58c2aac06 100644 --- a/projectify/static/css/dist/styles.css +++ b/projectify/static/css/dist/styles.css @@ -1246,10 +1246,6 @@ video { z-index: 10; } -.order-1 { - order: 1; -} - .order-last { order: 9999; } @@ -1489,10 +1485,6 @@ video { max-width: 4rem; } -.max-w-20 { - max-width: 5rem; -} - .max-w-2xl { max-width: 42rem; } @@ -1553,10 +1545,6 @@ video { flex-grow: 0; } -.basis-1\/4 { - flex-basis: 25%; -} - .-rotate-12 { --tw-rotate: -12deg; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); @@ -1852,11 +1840,6 @@ video { background-color: rgb(37 99 235 / var(--tw-bg-opacity, 1)); } -.bg-primary-hover { - --tw-bg-opacity: 1; - background-color: rgb(30 64 175 / var(--tw-bg-opacity, 1)); -} - .object-contain { -o-object-fit: contain; object-fit: contain; @@ -1933,11 +1916,6 @@ video { padding-right: 1rem; } -.px-5 { - padding-left: 1.25rem; - padding-right: 1.25rem; -} - .px-6 { padding-left: 1.5rem; padding-right: 1.5rem; @@ -1992,10 +1970,6 @@ video { padding-bottom: 5rem; } -.pb-4 { - padding-bottom: 1rem; -} - .pb-8 { padding-bottom: 2rem; } @@ -2351,10 +2325,6 @@ video { } @media (min-width: 640px) { - .sm\:order-2 { - order: 2; - } - .sm\:grid { display: grid; } @@ -2434,10 +2404,6 @@ video { display: grid; } - .md\:max-w-xs { - max-width: 20rem; - } - .md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2446,6 +2412,10 @@ video { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .md\:grid-cols-\[1fr_3fr\] { + grid-template-columns: 1fr 3fr; + } + .md\:flex-row { flex-direction: row; } @@ -2549,10 +2519,6 @@ video { max-width: 42rem; } - .lg\:max-w-xs { - max-width: 20rem; - } - .lg\:grid-cols-\[1fr_max-content\] { grid-template-columns: 1fr max-content; } @@ -2605,9 +2571,3 @@ video { grid-template-columns: repeat(3, minmax(0, 1fr)); } } - -@media (min-width: 1536px) { - .\32xl\:max-w-md { - max-width: 28rem; - } -} diff --git a/projectify/static/prose/prose.js b/projectify/static/prose/prose.js index b36062676..03b34d0ae 100644 --- a/projectify/static/prose/prose.js +++ b/projectify/static/prose/prose.js @@ -68,65 +68,67 @@ document.addEventListener("DOMContentLoaded", () => { }; }); -function initializeLinkSuggestions(editor, suggestLinksUrl, type) { - const dialog = document.getElementById("prose-link-suggestions-dialog"); - if (dialog === null) { - throw new Error("Couldn't find link suggestions dialog"); +function insertLink(trixEditor, dialog, event) { + if (!event.target.dataset.url) { + console.error("Received click event for a button with no data-url set"); + return; } - async function showWidget(trixEditor) { - const selectedRange = trixEditor.getSelectedRange(); - const selectedText = selectedRange[0] !== selectedRange[1]; - const searchString = selectedText - ? `?search=${trixEditor.getDocument().toString().slice(selectedRange[0], selectedRange[1])}` - : ""; + const { url, title } = event.target.dataset; + const [start, end] = trixEditor.getSelectedRange(); + const textSelected = start !== end; + if (textSelected) { + // Potential sink for XSS + // Trix relies on DOMPurify + trixEditor.activateAttribute("href", url); + } else { + const start = trixEditor.getPosition(); + trixEditor.insertString(title); + const end = trixEditor.getPosition(); + trixEditor.setSelectedRange([start, end]); + trixEditor.activateAttribute("href", url); + trixEditor.setSelectedRange([end, end]); + } + dialog.close(); +} - await htmx.ajax("GET", `${suggestLinksUrl}${searchString}`, { - target: dialog, - swap: "innerHTML", - }); +async function showWidget(trixEditor, suggestLinksUrl, dialog) { + const selectedRange = trixEditor.getSelectedRange(); + const selectedText = selectedRange[0] !== selectedRange[1]; + const searchString = selectedText + ? `?search=${trixEditor.getDocument().toString().slice(selectedRange[0], selectedRange[1])}` + : ""; - dialog.showModal(); + await htmx.ajax("GET", `${suggestLinksUrl}${searchString}`, { + target: dialog, + swap: "innerHTML", + }); - const closeButton = dialog.querySelector('[name="close"]'); - if (closeButton === null) { - throw new Error("Couldn't find close button"); - } - closeButton.addEventListener("click", () => { - dialog.close(); - }); - - // Find list of results inside dialog. The results hold url and - // title data- attributes - const results = document.getElementById("prose-link-suggestions-results"); - if (results === null) { - throw new Error("Couldn't find #prose-link-suggestions-results"); - } - results.addEventListener("click", (event) => { - if (!event.target.dataset.url) { - console.error( - "Received click event for a button with no data-url set", - ); - return; - } - - const { url, title } = event.target.dataset; - const [start, end] = trixEditor.getSelectedRange(); - const textSelected = start !== end; - if (textSelected) { - // Potential sink for XSS - // Trix relies on DOMPurify - trixEditor.activateAttribute("href", url); - } else { - const start = trixEditor.getPosition(); - trixEditor.insertString(title); - const end = trixEditor.getPosition(); - trixEditor.setSelectedRange([start, end]); - trixEditor.activateAttribute("href", url); - trixEditor.setSelectedRange([end, end]); - } - dialog.close(); - }); + dialog.showModal(); + + const closeButton = dialog.querySelector('[name="close"]'); + if (closeButton === null) { + throw new Error("Couldn't find close button"); + } + // XXX find out why this breaks + // closeButton.addEventListener("click", dialog.close); + // error: + // Uncaught TypeError: 'close' called on an object that does not implement interface HTMLDialogElement. + closeButton.addEventListener("click", () => dialog.close()); + + // Find list of results inside dialog. The results hold url and + // title data- attributes + const results = document.getElementById("prose-link-suggestions-results"); + if (results === null) { + throw new Error("Couldn't find #prose-link-suggestions-results"); + } + results.addEventListener("click", insertLink.bind(null, trixEditor, dialog)); +} + +function initializeLinkSuggestions(editor, suggestLinksUrl, type) { + const dialog = document.getElementById("prose-link-suggestions-dialog"); + if (dialog === null) { + throw new Error("Couldn't find link suggestions dialog"); } editor.addEventListener("trix-action-invoke", (event) => { @@ -138,7 +140,7 @@ function initializeLinkSuggestions(editor, suggestLinksUrl, type) { return; } const { editor: trixEditor } = event.target; - showWidget(trixEditor); + showWidget(trixEditor, suggestLinksUrl, dialog); }); } /*! SPDX-SnippetBegin @@ -199,9 +201,9 @@ function configureToolbar(event) { uploadFile(uploadUrl, event.attachment), ); } else { - editor.addEventListener("trix-file-accept", function (event) { - event.preventDefault(); - }); + editor.addEventListener("trix-file-accept", (event) => + event.preventDefault(), + ); const fileToolsGroup = toolbarElement.querySelector( ".trix-button-group--file-tools", ); @@ -219,23 +221,26 @@ function configureToolbar(event) { if (buttonGroup === null) { throw new Error("Couldn't find Trix button group"); } - const suggestLinksUrl = editor.dataset.suggestLinksUrl; - const suggestProjectsUrl = editor.dataset.suggestProjectsUrl; - const suggestProjectsButton = createActionButton( - "Project", - "Suggest projects", - "x-suggest-project", + const { suggestLinksUrl, suggestProjectsUrl } = editor.dataset; + buttonGroup.appendChild( + createActionButton("Project", "Suggest projects", "x-suggest-project"), ); - buttonGroup.appendChild(suggestProjectsButton); initializeLinkSuggestions(editor, suggestProjectsUrl, "project"); - - const suggestTasksButton = createActionButton( - "Task", - "Suggest tasks", - "x-suggest-task", + buttonGroup.appendChild( + createActionButton("Task", "Suggest tasks", "x-suggest-task"), ); - buttonGroup.appendChild(suggestTasksButton); initializeLinkSuggestions(editor, suggestLinksUrl, "task"); + const { suggestWikiUrl } = editor.dataset; + if (suggestWikiUrl !== undefined) { + buttonGroup.appendChild( + createActionButton( + "Wiki Page", + "Suggest Wiki pages", + "x-suggest-wiki", + ), + ); + initializeLinkSuggestions(editor, suggestWikiUrl, "wiki"); + } } editor.classList.add("initialized"); } diff --git a/projectify/storefront/markdown_en/security/disclose.md b/projectify/storefront/markdown_en/security/disclose.md index fbfaa7cf0..da6f9bced 100644 --- a/projectify/storefront/markdown_en/security/disclose.md +++ b/projectify/storefront/markdown_en/security/disclose.md @@ -4,23 +4,21 @@ SPDX-FileCopyrightText: 2024 JWP Consulting GK SPDX-License-Identifier: AGPL-3.0-or-later --> -# JWP Consulting GK Vulnerability Disclosure Policy - -## Introduction +# Introduction JWP Consulting GK welcomes feedback from security researchers and the general public to help improve our security. If you believe you have discovered a vulnerability, privacy issue, exposed data, or other security issues in any of our assets, we want to hear from you. This policy outlines steps for reporting vulnerabilities to us, what we expect, what you can expect from us. -## Systems in Scope +# Systems in Scope This policy applies to any digital assets related to Projectify that are owned, operated, and maintained by JWP Consulting GK. -## Out of Scope +# Out of Scope - Assets or other equipment not owned by parties participating in this policy. Vulnerabilities discovered or suspected in out-of-scope systems should be reported to the appropriate vendor or applicable authority. -## Our Commitments +# Our Commitments When working with us, according to this policy, you can expect us to: @@ -29,7 +27,7 @@ When working with us, according to this policy, you can expect us to: - Work to remediate discovered vulnerabilities in a timely manner, within our operational constraints; and - Extend Safe Harbor for your vulnerability research that is related to this policy. -## Our Expectations +# Our Expectations In participating in our vulnerability disclosure program in good faith, we ask that you: @@ -43,11 +41,11 @@ In participating in our vulnerability disclosure program in good faith, we ask t - You should only interact with test accounts you own or with explicit permission from the account holder; and - Do not engage in extortion. -## Official Channels +# Official Channels Please report security issues via [hello@projectifyapp.com](mailto:hello@projectifyapp.com), providing all relevant information. The more details you provide, the easier it will be for us to triage and fix the issue. -## Safe Harbor +# Safe Harbor When conducting vulnerability research, according to this policy, we consider this research conducted under this policy to be: diff --git a/projectify/storefront/markdown_en/security/general.md b/projectify/storefront/markdown_en/security/general.md index f56272818..618e75968 100644 --- a/projectify/storefront/markdown_en/security/general.md +++ b/projectify/storefront/markdown_en/security/general.md @@ -4,7 +4,7 @@ SPDX-FileCopyrightText: 2024 JWP Consulting GK SPDX-License-Identifier: AGPL-3.0-or-later --> -# Security +# Introduction This page explains measures taken by JWP Consulting GK (hereinafter referred to as "JWP") to ensure the security of the Projectify software (hereinafter diff --git a/projectify/storefront/static/hero-accessibility.png b/projectify/storefront/static/hero-accessibility.png deleted file mode 100644 index f761fd980..000000000 Binary files a/projectify/storefront/static/hero-accessibility.png and /dev/null differ diff --git a/projectify/storefront/static/hero-accessibility.png.license b/projectify/storefront/static/hero-accessibility.png.license deleted file mode 100644 index d723339a2..000000000 --- a/projectify/storefront/static/hero-accessibility.png.license +++ /dev/null @@ -1,3 +0,0 @@ -SPDX-FileCopyrightText: 2024 JWP Consulting GK - -SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/projectify/storefront/static/hero-accessibility.webp b/projectify/storefront/static/hero-accessibility.webp deleted file mode 100644 index 970feb237..000000000 Binary files a/projectify/storefront/static/hero-accessibility.webp and /dev/null differ diff --git a/projectify/storefront/templates/solutions_base.html b/projectify/storefront/templates/solutions_base.html deleted file mode 100644 index 7db97139b..000000000 --- a/projectify/storefront/templates/solutions_base.html +++ /dev/null @@ -1,15 +0,0 @@ -{# SPDX-FileCopyrightText: 2025 JWP Consulting GK #} -{# SPDX-License-Identifier: AGPL-3.0-or-later #} -{% extends "base.html" %} -{% block body %} -{% trans "Our platform fully complies with GDPR regulations, amongst others, so you can rest assured that your private information stays private." %}
- {% url "storefront:privacy" as pp_url %} - {% anchor pp_url label=_("Learn more about privacy and Projectify") %} + {% anchor "storefront:privacy" label=_("Learn more about privacy and Projectify") %}{% trans "We respect your freedom and privacy and provide you with the source code under a Free Software license. The Projectify application is licensed under the GNU Affero General Public License (AGPL) version 3.0 or later." %}
- {% url "storefront:free_software" as learnmore_url %} - {% anchor learnmore_url label=_("Learn more about Free Software") %} + {% anchor "storefront:free_software" label=_("Learn more about Free Software") %}tag. This is +useful when you're rendering a form with .as_p() HTML forbids block +elements like